Skip to Content

Payment webhook

Your server registers one or more endpoint URLs per project in the dashboard under Developers, section Webhook endpoints (Webhooks). Each endpoint gets its own secret, shown once at creation and on rotation. Every active endpoint of a project receives every payment event and every payout event of that project.

Delivery rows are written in the same database transaction as the payment change, so an event is never lost between the change and the queue. A worker sends them; SEND_WEBHOOKS=false in the install’s .env stops all sending without discarding the queue.

Delivery

POST <your endpoint URL> Content-Type: application/json X-Recv-Signature: sha256=<hex> X-Payram-Signature: sha256=<hex> X-Recv-Event: payment.filled X-Recv-Delivery: 1042
HeaderValue
X-Recv-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of the exact request body, keyed with the endpoint’s secret string.
X-Payram-SignatureThe same value, for handlers written against PayRam. Note that PayRam keys its HMAC with the project API key; recv.fund keys it with the endpoint secret.
X-Recv-EventThe event name (table below).
X-Recv-DeliveryThe delivery id, a decimal integer as a string. Stable across every retry of the same delivery.

Endpoint URLs must be http: or https: with no credentials or fragment. They may be on your internal network.

Events

X-Recv-EventPayload statusWhen
payment.openOPENThe payment was created.
payment.confirmingOPENA transfer’s confirmation count changed. One delivery per newly observed count per transfer; confirmation_current and confirmation_required carry the progress.
payment.filledFILLEDConfirmed funds are within the fill tolerance of the requested amount.
payment.partially-filledPARTIALLY_FILLEDConfirmed funds are below the requested amount.
payment.over-filledOVER_FILLEDConfirmed funds exceed the requested amount.
payment.cancelledCANCELLEDThe payment expired while open or was cancelled from the dashboard.

Flow: OPEN → one payment.confirming per confirmation step → FILLED, PARTIALLY_FILLED or OVER_FILLED; or OPEN → CANCELLED. A transfer confirmed after cancellation still produces a filled-state event. A partially filled payment can later become filled or over-filled.

Payload

{ "customer_id": "1234", "invoice_id": "INV-0090", "reference_id": "c80f5363-0397-4761-aa1a-3155c3a21470", "status": "FILLED", "amount": "50.000000", "currency": "USDC", "network": "SOLANA", "filled_amount": "50.000000", "filled_amount_in_usd": "50.000000", "sponsored_amount": "0", "sponsored_amount_in_usd": "0", "timestamp": 1758524022, "payment_info": [ { "source_address": "7a2CkQ1v8mT5yRbGxWn3uHs9dLp4eJf6cVa2NzKq8YtB", "transaction_hash": "5KjP2n8vQ4wR7tY1uI3oA6sD9fG2hJ4kL7zX0cV5bN8mQ1wE3rT6yU9iO2pA5sD8f", "destination_address": "4Nd1mYw9zqXxq2Vb7Lr8KfJ3sT6uHc1pQe9RaBwDgF2h", "block_number": 301234567, "kind": "deposit", "currency_id": 3, "amount_raw": "50000000", "fee_raw": null, "status": "confirmed" } ], "confirmation_current": 1, "confirmation_required": 1 }
FieldTypeMeaning
customer_idstringYour customerID.
invoice_idstringYour invoiceID, or "".
reference_idstringThe payment’s reference id.
statusstringOPEN, PARTIALLY_FILLED, FILLED, OVER_FILLED or CANCELLED.
amountstringRequested USD amount.
currencystringSelected currency code, or "" before selection.
networkstringChain code of the selected chain (or, for payment.confirming, of the transfer’s chain), or "".
filled_amountstringConfirmed amount in units of the selected currency, padded to its decimals. "0" before selection.
filled_amount_in_usdstringConfirmed USD amount, six decimals.
sponsored_amountstringAlways "0". Kept for PayRam field compatibility.
sponsored_amount_in_usdstringAlways "0".
timestampintegerUnix time in seconds when the event was recorded.
payment_infoarrayUp to 100 transfers recorded against this payment, oldest first, including transfers not yet confirmed.
payment_info[].source_addressstringSending address.
payment_info[].transaction_hashstringTransaction hash or Solana signature.
payment_info[].destination_addressstringReceiving address: the customer’s deposit address, or for an SPL token its token account. A wallet checkout payment is a transfer to the same address.
payment_info[].block_numberintegerBlock or slot.
payment_info[].kindstringdeposit. Every transfer, including a wallet checkout payment, is a transfer to a deposit address. The value checkout-pay belonged to the superseded program payment path and is not written by this release.
payment_info[].currency_idintegerCatalog id of the transferred currency.
payment_info[].amount_rawstringTransferred amount in base units of that currency. The full amount arrives at the deposit address.
payment_info[].fee_rawstring or nullnull. No fee is taken when a customer pays; the fee is taken at sweep.
payment_info[].statusstringdetected, confirmed or orphaned.
confirmation_currentintegerFor payment.confirming, the transfer’s new count. Otherwise the lowest count across transfers, 0 with none.
confirmation_requiredintegerThe chain’s threshold, 0 when no chain is set.

All amounts are strings. The payload is a snapshot taken when the event was recorded and does not change on retry.

Verifying the signature

Compute HMAC-SHA256 over the raw request bytes with the endpoint secret, prefix sha256=, and compare with X-Recv-Signature in constant time. Read the raw body before any JSON parsing or re-serialization; a re-serialized body will not match.

import { createHmac, timingSafeEqual } from 'node:crypto'; import express from 'express'; const secret = process.env.RECV_WEBHOOK_SECRET; const app = express(); app.post('/webhooks/recv', express.raw({ type: 'application/json' }), (req, res) => { const expected = Buffer.from( `sha256=${createHmac('sha256', secret).update(req.body).digest('hex')}`, ); const given = Buffer.from(req.get('X-Recv-Signature') ?? ''); if (given.length !== expected.length || !timingSafeEqual(given, expected)) { return res.status(401).end(); } const deliveryId = req.get('X-Recv-Delivery'); const event = JSON.parse(req.body.toString('utf8')); // 1. If deliveryId has been processed, return 200 without doing anything. // 2. Record deliveryId, then handle event.status for event.reference_id. res.status(200).end(); });

After a secret rotation the next attempt of every pending delivery is signed with the new secret. Keep the old secret accepted only for as long as you need to drain in-flight retries.

Retries

Only an HTTP 2xx response counts as delivered. A 3xx is not followed and counts as a failure, as does any other status, a connection error, or no response within webhook.timeoutMs (default 60000 ms; 100 to 60000). The response body is discarded.

Failed attempts are retried on webhook.retryScheduleSeconds. The default gives ten attempts in total:

AttemptDelay after the previous failure
1immediate
22 s
34 s
430 min
51 h
62 h
74 h
88 h
924 h
1048 h

After the last attempt the delivery is marked failed. The dashboard shows each delivery’s status, attempt count, last HTTP result and next attempt time, and lets an owner, admin or project lead retry a failed delivery by hand, which restarts the schedule. An endpoint that is disabled pauses its deliveries; enabling it makes overdue ones eligible again. An endpoint added later does not receive events recorded before it existed.

The payment’s webhookStatus in the dashboard summarizes its deliveries: pending if any is pending, otherwise failed if any failed, otherwise received.

Handling deliveries

  • Deduplicate on X-Recv-Delivery. Delivery is at least once: your handler may run before a timeout or crash on the sending side makes the same delivery retry. Store the id before doing work, and answer 200 for an id you have already seen.
  • Tolerate out-of-order events. Retries of an old event can arrive after a newer one. Make decisions from status and the state you already hold, or read Payment status after receiving an event.
  • Answer quickly. Queue the work and return 200; the 60 second timeout is the maximum, not a target.
  • Do not trust the body alone for fulfillment. The signature proves the body came from your install. Reading the status endpoint from your server proves the current state.
  • Use reference_id, not invoice_id, as the key. invoice_id is your label and may repeat.
Last updated on