Paystable delivers final hold outcomes to the merchant callback_url with a signed HTTP POST. The merchant backend should use this callback as the trusted trigger for fulfillment, release, or manual escalation.
Frontend polling and SSE are useful for display. They should not be the source of fulfillment truth.
POST <callback_url>
Content-Type: application/json
X-Paystable-Signature: v2=<hex-hmac>
X-Paystable-Idempotency-Key: <opaque-key>
X-Paystable-Timestamp: <unix-seconds>{
"txn_id": "order_abc123",
"event": "transaction.confirmed",
"status": "CONFIRMED",
"amount": 49900,
"currency": "INR",
"gateway": "payu",
"verified_at": "2026-06-24T12:00:19Z",
"metadata": {
"order_id": "order_abc123"
}
}For review states, Paystable includes additional context:
{
"txn_id": "order_abc123",
"event": "transaction.mismatch",
"status": "MISMATCH",
"amount": 49900,
"currency": "INR",
"gateway": "payu",
"verified_at": "2026-06-24T12:00:19Z",
"reason": "amount_mismatch",
"gateway_amount": 39900,
"hold_amount": 49900,
"metadata": {}
}When an operator resolves a review as confirmed or failed, Paystable sends the same final event shape with "reason":"manual_review" and "resolution_source":"manual_review". A no_action decision is recorded in the audit ledger and does not send a callback. Repeating the same review request is accepted without queuing another delivery.
| Field | Type | Notes |
|---|---|---|
txn_id |
string | Merchant-supplied hold ID. |
event |
string | transaction.confirmed, transaction.failed, transaction.indeterminate, or transaction.mismatch. |
status |
string | CONFIRMED, FAILED, INDETERMINATE, or MISMATCH. |
amount |
int64 | Hold amount in smallest currency unit. |
currency |
string | ISO 4217. Current normal value: INR. |
gateway |
string | Gateway name. Current adapter: payu. |
verified_at |
string | RFC 3339 timestamp generated by Paystable. |
metadata |
object | Original hold metadata. |
reason |
string | Present for failure/review cases when Paystable has a reason. |
gateway_amount |
int64 | Present for amount review cases. |
hold_amount |
int64 | Present for amount review cases. |
resolution_source |
string | manual_review when an operator produced the final outcome. |
Paystable uses HMAC-SHA256 with MERCHANT_CALLBACK_SECRET. The signature header is v2=<lowercase-hex-hmac>.
The signed bytes are the UTF-8 prefix below followed immediately by the unchanged raw request body:
v2\n<timestamp>\n<idempotency-key>\n<raw-body>
Each \n represents one LF byte. Use the exact values from X-Paystable-Timestamp and X-Paystable-Idempotency-Key.
The key must be nonempty and contain no CR or LF. The timestamp must be canonical decimal Unix seconds and within five minutes of the receiver's current time.
Retries keep the same key and body but receive a fresh signed delivery timestamp.
Breaking change: Update merchant verifiers when upgrading Paystable. Reject legacy body-only sha256= signatures; accepting them as a fallback permits header tampering.
Verify the signature and timestamp before JSON parsing or trusting the event key:
func verify(body []byte, signature, key, timestamp, secret string) bool {
if secret == "" || key == "" || strings.ContainsAny(key, "\r\n") {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
now := time.Now().Unix()
if err != nil || strconv.FormatInt(ts, 10) != timestamp || ts < now-300 || ts > now+300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte("v2\n" + timestamp + "\n" + key + "\n"))
_, _ = mac.Write(body)
expected := "v2=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}Pass the raw body and all three callback header values to this verifier.
Paystable delivers with at-least-once semantics. The same event may be sent more than once if the merchant endpoint times out, returns a retryable status, or the network fails after processing.
Rules for merchants:
- Treat
X-Paystable-Idempotency-Keyas an opaque string. - After signature verification, store the key and apply fulfillment in the same database transaction.
- Enforce one fulfillment per
txn_idwith a database constraint as well as event-key deduplication. - If the key was already processed, return
2xxand do nothing. - Never infer business meaning from the key format.
| Merchant response | Paystable behavior |
|---|---|
2xx |
Marks delivery complete. |
4xx except 429 |
Treats as permanent, marks exhausted, alerts ops. |
429, 5xx, timeout, connection refused |
Retries with backoff until max_attempts. |
Respond within DELIVERY_TIMEOUT_S seconds. Default: 10.
- Verify the v2 signature over the timestamp, event key, and raw body.
- Reject signed timestamps more than five minutes in the past or future.
- Deduplicate by
X-Paystable-Idempotency-Key. - Return
2xxonly after your database transaction commits. - Use HTTPS callback URLs in production.
- Set
DELIVERY_ALLOW_INSECURE_CALLBACK=trueonly for local development.