Webhooks#
Webhooks are the authoritative record of what happened. The browser redirect to your success_url is a convenience - the tenant can close the tab before it fires. Never mark an order paid on the redirect alone.
Events#
| Event | Fired when |
|---|---|
gateway.session.completed | Payment succeeded. The invoice is booked and paid. |
gateway.session.cancelled | The session was cancelled by the tenant, by you, or by the system. |
gateway.session.expired | The session passed expires_at unpaid. |
gateway.payment.failed | A payment attempt failed but the session is still usable. |
gateway.verification.failed | Identity verification failed. |
payment.failed is a soft error
The session stays open and the tenant can retry on the same gateway_url. Do not treat it as terminal - wait for session.completed, session.cancelled or session.expired.
Payload#
Every payload carries event, session_id and timestamp. Blocks are added when relevant.
{
"event": "gateway.session.completed",
"session_id": "gwy_9mK2xQ7pLr4vT1sZ",
"timestamp": "2026-03-01T09:12:33+00:00",
"tenant": {
"id": 1152,
"email": "tenant@example.com",
"first_name": "Mari",
"last_name": "Tamm",
"verification_status": "verified",
"verified_at": "2026-03-01T09:05:11+00:00"
},
"payment": {
"cash_amount": 666.0,
"guaranteed_amount": 800.0,
"total_amount": 800.0,
"currency": "EUR",
"status": "paid",
"paid_at": "2026-03-01T09:12:30+00:00",
"payment_method": "everypay_card",
"transaction_id": "a1b2c3d4"
},
"deposit": {
"amount": 800.0,
"mode": "choice",
"resolved_mode": "deposit_guaranteed"
},
"guarantee": {
"guarantee_id": "grt_7Yh2",
"covered_amount": 800.0,
"status": "active",
"activated_at": "2026-03-01T09:12:31+00:00"
},
"payment_agreement_id": 4821,
"agreement": { "id": 4821, "agreement_type": "ontime" },
"invoice_id": 91733,
"metadata": { "property_id": "prop_456" }
}On a cancellation you also get a cancellation block:
{
"event": "gateway.session.cancelled",
"session_id": "gwy_9mK2xQ7pLr4vT1sZ",
"timestamp": "2026-03-01T10:02:00+00:00",
"cancellation": {
"reason": "invoice_paid_externally",
"code": "CANCEL_INVOICE_PAID_EXTERNALLY",
"source": "system",
"note": null,
"cancelled_at": "2026-03-01T10:02:00+00:00"
}
}Cancellation reasons#
| Reason | Source | Meaning |
|---|---|---|
customer_cancelled | tenant | The tenant abandoned checkout. |
operator_cancelled | operator | You cancelled it via the API or dashboard. |
invoice_paid_externally | system | The invoice was settled another way, so the session was closed. |
invoice_cancelled | system | The underlying invoice was cancelled or rejected. |
invoice_not_payable | system | The invoice was no longer payable at completion time. |
payment_agreement_mismatch | system | Safety guard: session and invoice disagreed on the agreement. |
Verifying the signature#
Each delivery carries two headers:
| Header | Value |
|---|---|
X-CasaPay-Timestamp | Unix seconds when the request was signed. |
X-CasaPay-Signature | sha256= followed by the hex HMAC-SHA256 digest. |
The signed payload is the timestamp, a literal ., then the raw request body:
sha256=hex(HMAC_SHA256(secret, timestamp + "." + raw_body))import crypto from 'node:crypto';
function verify(rawBody, timestamp, signature, secret) {
const expected =
'sha256=' +
crypto.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody)
.digest('hex');
// Constant-time compare to avoid leaking the digest through timing.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Verify against the raw body
Sign the exact bytes you received. If your framework parses the JSON and you re-serialise it, key order and slash escaping can change and the signature will never match. Capture the raw body before any JSON middleware runs.
Delivery and retries#
- Deliveries are queued, so they arrive shortly after the event rather than synchronously.
- Respond
2xxquickly. Do the slow work asynchronously. - Non-2xx responses and timeouts are retried with backoff, up to the configured maximum attempts.
- A disabled endpoint fails terminally instead of retrying.
Endpoints are filtered by environment and by subscribed events, so a test-mode session never notifies a live-only endpoint.
Make handlers idempotent
A payment can notify you more than once - EveryPay reports success on both the browser return and the server-to-server webhook, and retries can duplicate a delivery. Key your handler on session_id plus event and ignore repeats.
Managing endpoints#
Create, list, rotate secrets for and test webhook endpoints from the operator dashboard under Developers, or via the operator webhook endpoint API. Each endpoint has its own secret and event subscription list, and keeps a delivery log you can inspect when debugging.