Docs / Webhook guide
PayRam webhooks: signature verification, retries and idempotency
PayRam sends a webhook to your server whenever a payment changes state. This guide covers how to receive it, how to confirm it really came from PayRam, and how to handle duplicate and missed notifications.
The short version: register an HTTPS endpoint in the dashboard and PayRam will POST a JSON payload to it every time a payment changes state. Your server must do three things: verify the X-Payram-Signature header, return a 2xx straight away, and de-duplicate by reference_id plus status.
1. Register an endpoint in the dashboard
- In the dashboard go to Settings → Webhook and click Add Endpoint.
- Enter your endpoint URL, which must be a publicly reachable HTTPS address, add a short description and save.
- Mark the endpoint as active. PayRam delivers events to every active endpoint registered for the project.
Each project has its own API key and its own endpoints, so if you run several projects you configure each one separately. See multiple projects.
2. What the notification contains
The request is a POST with Content-Type: application/json. Amounts are strings; timestamp, confirmation_* and block_number are numbers. Example:
{
"customer_id": "1234",
"invoice_id": "INV-0090",
"reference_id": "a1b2c3d4e5",
"status": "FILLED",
"amount": "323.53",
"currency": "USDT",
"filled_amount": "323.53",
"filled_amount_in_usd": "323.53",
"timestamp": 1750340282,
"payment_info": [
{
"source_address": "0x7a2C…9bF1",
"transaction_hash": "0xabc123…def456",
"destination_address": "0x21d4cF2E…EA8d45",
"block_number": 21897412
}
],
"confirmation_current": 12,
"confirmation_required": 12
}
| Field | Meaning |
|---|---|
reference_id | PayRam's unique reference for the payment. Use it as your idempotency key |
invoice_id | The order number you passed when creating the payment, returned unchanged so you can reconcile |
customer_id | Your own identifier for the paying customer |
status | Payment state: OPEN, PARTIALLY_FILLED, FILLED, OVER_FILLED or CANCELLED. See payment statuses |
amount / currency | The requested amount and currency |
filled_amount | Amount received so far. May be null before a deposit is detected |
payment_info | On-chain deposit details (source address, transaction hash, destination address, block number). Empty until there is a deposit |
confirmation_current / confirmation_required | Confirmations seen and confirmations required, for example 3 / 12. Both are 0 on the final closed or cancelled notification |
While a deposit is confirming you also receive progress notifications, which you can use to show "confirming 5 / 12" on your own page.
3. Verify the signature (do not skip this)
Your endpoint is public, so anyone can send requests to it. You must confirm a request really came from PayRam. The recommended method is the X-Payram-Signature header:
- Algorithm: HMAC-SHA256.
- What is signed: the raw bytes of the request body.
- Key: your project API key.
- Format:
sha256=<hex digest>.
The most common mistake: parsing the body as JSON and re-serialising it before computing the signature. Any difference in key order or whitespace breaks the match. Compute the signature over the bytes exactly as received.
Requests may also carry an API-KEY header containing your project API key verbatim. That is a legacy method kept for backward compatibility. New code should use the signature.
4. Full examples: Node.js and Python
Node.js (Express)
The key point is to use express.raw to get the raw body, not express.json.
const express = require('express');
const crypto = require('crypto');
const app = express();
const API_KEY = process.env.PAYRAM_API_KEY;
function verify(rawBody, signatureHeader) {
const expected =
'sha256=' + crypto.createHmac('sha256', API_KEY).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader || '');
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/payram/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('X-Payram-Signature'))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge quickly, then process asynchronously so a slow job cannot trigger a retry
res.sendStatus(200);
handleEvent(event).catch(console.error);
});
app.listen(3000);
Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
API_KEY = os.environ["PAYRAM_API_KEY"].encode()
@app.post("/payram/webhook")
def payram_webhook():
raw = request.get_data() # raw bytes; do not use request.json here
sent = request.headers.get("X-Payram-Signature", "")
expected = "sha256=" + hmac.new(API_KEY, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sent, expected):
abort(401)
event = request.get_json()
# Put the event on a queue for a background worker and return immediately
enqueue(event)
return "", 200
Keep the API key in an environment variable on your server. Never put it in a web page or a mobile app.
5. Retry rules: when a delivery is resent
- What counts as success: your server returns
2xx. A response of 400 or above, or no response within 60 seconds, counts as a failure and triggers a retry. - Confirmation-progress notifications: retried up to 3 times per cycle (immediately, then after 2 s and 4 s) and sent again on the next poll until the payment is filled.
- Final notifications (closed or cancelled): retried quickly three times (immediately, 2 s, 4 s), then moved to long-term retry at 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, 24 hours and 48 hours. After the seventh long-term retry the delivery is marked failed and PayRam stops trying.
So a short outage on your side will not lose notifications. But if your server is down for a long time, a final notification may run out of retries. In that case use the status endpoint to catch up, as described in the next section.
6. Idempotency and reconciliation
The same status can be delivered more than once, for example when your server answers slowly and a retry fires. Your handler must therefore be idempotent: handling the same notification once or ten times must give the same result.
- Use
reference_id(or your owninvoice_id) plusstatusas a unique key. - On arrival, check whether that combination was already processed. If so, return
200and do not ship or credit again. - If not, record the combination and update the order in the same database transaction. A unique constraint on the combination stops concurrent duplicates.
Do not rely on webhooks alone. Treat them as a signal to go and look. For critical order state, confirm with the status endpoint, and reconcile regularly: take every order of yours that is not yet complete and query its current state by reference_id. The API integration guide shows how.
7. FAQ
My signature never matches. Why?
Nine times out of ten it is because the raw bytes were not used. Check three things: you use express.raw or request.get_data(); the key is the API key of this project (each project has its own); and you compare the whole header including the sha256= prefix.
Can I just check the API-KEY header?
You can, but it is the legacy method. The official recommendation is X-Payram-Signature, which also proves the body was not altered.
Can I ship when I receive FILLED?
FILLED means the filled amount equals the requested amount and the required confirmations were reached. For most merchants that is the signal to ship. For underpayments and overpayments, see payment statuses.
How do I test when my endpoint runs on my own machine?
The endpoint must be a publicly reachable HTTPS address. During local development you can expose a local port temporarily with a tunnelling tool. Remember to switch the endpoint in the dashboard to your real address afterwards.
Does the official SDK save work?
Yes. The official TypeScript / JavaScript SDK ships ready-made webhook handlers for Express, Fastify and Next.js, but by default they check the legacy API-Key header, so if you want signature verification you still add it yourself. See the SDK guide.
Related reading
- Create payments via API: Add crypto payments to your own site with the PayRam API: create a payment and get a payment link, send the customer to the payment page, confirm receipt with webhooks and the status endpoint, and reconcile by order number. With curl, Node.js and Python examples.
- Payment statuses: What PayRam's five payment statuses mean, how to handle underpaid (PARTIALLY_FILLED), overpaid (OVER_FILLED) and expired (CANCELLED) payments, and how to keep your order status in sync using webhooks and the status endpoint.
- TypeScript SDK: Use the official TypeScript / JavaScript SDK to integrate PayRam: install, create a client, create payments, check status, receive webhooks (Express, Fastify, Next.js), how it verifies requests and what you should add.
