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: sha256=<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 signs the raw request body with HMAC-SHA256 using MERCHANT_CALLBACK_SECRET. The header value is hex encoded and prefixed with sha256=.
Verify the raw bytes before JSON parsing:
func verify(body []byte, header, secret string) bool {
if !strings.HasPrefix(header, "sha256=") {
return false
}
got, err := hex.DecodeString(strings.TrimPrefix(header, "sha256="))
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
return hmac.Equal(mac.Sum(nil), got)
}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. - Store it before taking irreversible action.
- 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
X-Paystable-Signatureon the raw body. - Reject old timestamps if your app has replay protection.
- 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.