Skip to content

Latest commit

 

History

History
135 lines (108 loc) · 5.28 KB

File metadata and controls

135 lines (108 loc) · 5.28 KB

Callback Contract

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.

Request

POST <callback_url>
Content-Type: application/json
X-Paystable-Signature: v2=<hex-hmac>
X-Paystable-Idempotency-Key: <opaque-key>
X-Paystable-Timestamp: <unix-seconds>

Payload

{
  "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.

Signature Verification

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.

Idempotency

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-Key as an opaque string.
  • After signature verification, store the key and apply fulfillment in the same database transaction.
  • Enforce one fulfillment per txn_id with a database constraint as well as event-key deduplication.
  • If the key was already processed, return 2xx and do nothing.
  • Never infer business meaning from the key format.

Response Contract

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.

Security Checklist

  • 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 2xx only after your database transaction commits.
  • Use HTTPS callback URLs in production.
  • Set DELIVERY_ALLOW_INSECURE_CALLBACK=true only for local development.