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#

EventFired when
gateway.session.completedPayment succeeded. The invoice is booked and paid.
gateway.session.cancelledThe session was cancelled by the tenant, by you, or by the system.
gateway.session.expiredThe session passed expires_at unpaid.
gateway.payment.failedA payment attempt failed but the session is still usable.
gateway.verification.failedIdentity 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#

ReasonSourceMeaning
customer_cancelledtenantThe tenant abandoned checkout.
operator_cancelledoperatorYou cancelled it via the API or dashboard.
invoice_paid_externallysystemThe invoice was settled another way, so the session was closed.
invoice_cancelledsystemThe underlying invoice was cancelled or rejected.
invoice_not_payablesystemThe invoice was no longer payable at completion time.
payment_agreement_mismatchsystemSafety guard: session and invoice disagreed on the agreement.

Verifying the signature#

Each delivery carries two headers:

HeaderValue
X-CasaPay-TimestampUnix seconds when the request was signed.
X-CasaPay-Signaturesha256= 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 2xx quickly. 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.