Skip to content

Webhooks

Settlement instructions, status callbacks, signatures and retries.

Two directions

  • Settlement instructions (us to you). When a transfer is paid, SmartRemit sends a signed instruction to your settlement endpoint telling your rail to pay out.
  • Status callbacks (you to us). Your rail reports the transfer's progress back to SmartRemit, signed with your webhook secret.

Both directions use the same timestamped signature, described below.

Settlement instructions

SmartRemit sends a POST to your settlement endpoint:

POST https://partner.example.com/settlements
x-smartremit-signature: t=1790000000,v1=5d2e…
Content-Type: application/json

{
  "reference": "tr_abc123",
  "partner_id": "acme",
  "corridor": { "source": "US", "destination": "IN" },
  "payout":   { "rail": "bank", "destination": "123456789012|HDFC0001234" },
  "recipient":{ "name": "Anita Sharma", "phone": "+12025550143" },
  "amount": {
    "source": 200, "currency": "USD",
    "destination": 16600, "destination_currency": "INR",
    "fx_rate": 83
  }
}
  • reference is our transfer ID. Echo it in your status callbacks.
  • The instruction is at least once: deduplicate on reference, so a retry can never pay out twice.
  • Answer 2xx, optionally with a JSON body carrying providerRef (your reference, stored once against the transfer).
  • When you sent a client_reference and your own rail pays the transfer out, the instruction carries it as client_reference. A transfer paid out by another partner's rail never carries it.

Compliance block

Instructions also carry an additive compliance object. Ignore unknown keys, and do not reject an instruction that lacks it.

"compliance": {
  "version": 1,
  "originator": {
    "entity_type": "individual",
    "name": "Ravi Kumar", "country": "US", "phone": "12025550142",
    "id_type": "passport", "id_last4": "1234"
  },
  "beneficiary": { "entity_type": "individual", "name": "Anita Sharma",
                   "relationship": "parent", "country": "IN" },
  "purpose": "family_support",
  "purpose_code": null,
  "kyc": { "status": "verified", "tier": "T1", "verified_at": "2026-09-01T12:00:00.000Z" },
  "screening": { "status": "cleared", "reasons": [],
                 "screened_at": "2026-09-23T10:00:00.000Z",
                 "list_source": "…", "list_version": "…", "decision": "clear" },
  "edd_required": false
}
  • version is 1. originator has entity_type (individual or business; for a business, name is the business), name, country, phone, id_type and id_last4.
  • Never sent: the full ID number, date of birth, email or residential address. The originator's address is not included yet.
  • purpose is our purpose value. purpose_code is always null for now.
  • screening.list_source, list_version and decision appear only when a sanctions screening record exists for the transfer.
  • A transfer settled on another partner's rail carries "routed": true and originator: null: the sender's identity is not shared with another partner. originator is also null when it could not be loaded, and older instructions have no block.
  • The signatures cover the block like every other byte of the body.

Reversals

A cancelled transfer whose funds your rail pulled gets a signed reverse instruction. It has "action": "reverse" and its own reference, distinct from the original instruction's, so your rail can tell a reverse from a replay. A reverse for a debit that never happened must be a no-op on your side.

Ack deadline

We wait at most 15 seconds for your 2xx. A slower answer is treated as a failure and the same instruction is retried. Persist and acknowledge first, then process asynchronously.

Endpoint rules

  • A public https:// host on port 443. No IP addresses, no internal or single-label host names, no credentials in the URL.
  • We follow at most 2 redirects, and only 307 or 308 to the same origin. A 301, 302 or 303 would turn the signed POST into a GET, so it is refused.
  • Your acknowledgement must be 64 KB or less, uncompressed: we send Accept-Encoding: identity and never decompress. providerRef is at most 128 characters of letters, digits and . _ : -.

An endpoint that breaks these rules is refused before the instruction is sent; the instruction is retried, so fix the endpoint on your dashboard (Integrations → Settlement webhooks) and the retries pick it up.

Retries

Instructions are delivered at least once. A failed attempt is retried with exponential backoff: roughly 2^n seconds after the n-th attempt, capped at 1800 seconds between attempts. After 56 attempts (about a day in total) the instruction stops retrying and SmartRemit's operations team is alerted to resolve it with you.

Status callbacks

Report each transfer's status to the callback URL shown on your dashboard's partner page (it has the shape https://smartremit.ai/api/payment-webhook/{provider}), signed with your webhook secret:

POST https://smartremit.ai/api/payment-webhook/acme-rail
x-smartremit-signature: t=1790000000,v1=9c44…
Content-Type: application/json

{ "reference": "tr_abc123", "status": "paid_out",
  "amount": { "destination": 16600, "destination_currency": "INR" } }
StatusMeaning
createdAwaiting payment (no change).
fundedPaid: the customer was charged on your side.
paid_outDelivered. Triggers the delivery notifications.
failedCancelled. If SmartRemit captured the charge it is refunded; a partner-pulled debit gets a signed reverse; otherwise you return the funds on your side. The customer is told.
returnedCancelled, as for failed: if SmartRemit captured the charge it is refunded; a partner-pulled debit gets a signed reverse; otherwise you return the funds on your side. The customer is told.
  • The state machine only moves forward, so replays and out-of-order callbacks are safe.
  • A failed or returned may carry an optional reason string. It is stored on the transfer for your operations team and ours, cut to 200 characters.
  • A failed after paid_out is recorded for operations and never reverses a delivery. A paid_out after failed is refused and the transfer stays cancelled.
  • Send your payout confirmation (for example a bank UTR) as payout_reference on paid_out: 1 to 64 letters, digits and . _ : / # -. We save the first one; later ones, and a value that does not fit, are ignored, and the status still applies.
  • Echo the amount on paid_out. Send back the amount.destination and destination_currency from our instruction. If they differ, the transfer is not marked delivered: it is held for our operations team, who resolve it with you, and we answer with "held": true so you stop retrying. A paid_out with no amount is accepted for now but is deprecated.
  • Unsigned, mis-signed and out-of-window callbacks are rejected with 401.

Signatures

Every instruction and every callback carries:

x-smartremit-signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( secret, t + "." + rawBody ) )

Use your signing secret to verify our instructions, and your webhook secret to sign your callbacks. To verify:

signed   = t + "." + rawBody          (the exact bytes received)
expected = hex(HMAC_SHA256(secret, signed))
valid    = |now - t| within the tolerance
           AND some v1 in the header equals expected (constant-time compare)
  • The tolerance is 300 seconds either side of now. Reject anything outside it.
  • Compare in constant time.
  • Keep a record of the messages you have processed (for example a hash of t.rawBody) for at least 15 minutes, and answer a repeat with 2xx without acting on it. We do the same: a repeated callback gets { "ok": true, "duplicate": true }.
  • Secret rotation. When a secret is changed, the old one stays valid for 7 days. During that time our instructions carry one v1 per active secret: accept the message if any of them matches. Your callbacks may be signed with either secret.
  • Deprecated: the older x-signature header (an HMAC of the raw body with no timestamp) is still sent, and still accepted when x-smartremit-signature is absent. It will be retired, so verify the timestamped header now. When both are present, x-smartremit-signature alone decides.