Docs/Events
View Markdown

Webhooks

Relay sends signed HTTPS requests when payment or wallet state changes. Delivery is at-least-once: the same event can arrive more than once, and different events for one resource can arrive out of order.

Use webhooks for prompt updates and a GET endpoint for reconciliation.

Configure an endpoint#

Webhook endpoints are an administrative control in the Relay dashboard, not an accesskey endpoint. A team member with webhook administration permission can add an endpoint and choose one or more action groups:

ActionDelivers
payment_intentCrypto payment-intent lifecycle events
fiat_payment_intentFiat payment-intent lifecycle events
wallet_eventExternal-wallet and customer-wallet events

Your endpoint must:

  • use public HTTPS on port 443;
  • resolve only to public IP addresses;
  • not contain embedded credentials or a URL fragment; and
  • accept requests without relying on redirects, because Relay does not follow them.

Relay reveals the endpoint signing secret when the endpoint is created. Store the whsec_… value immediately; normal endpoint reads do not expose it.

Delivery request#

http
POST /relay/webhooks HTTP/1.1
Content-Type: application/json
Relay-Event-Id: 56bb88e4-682a-4211-9847-144becfb5266
Relay-Signature: t=1789905920,v1=cb24b9…

The signature header can temporarily contain two v1 values during secret rotation. Accept the request when any valid v1 signature matches an active secret.

Event envelope#

json
{
  "id": "56bb88e4-682a-4211-9847-144becfb5266",
  "type": "payment_intent.funded",
  "schemaVersion": "1",
  "occurredAt": "2026-09-20T12:05:20.000Z",
  "resourceVersion": 3,
  "data": {
    "txId": "PMI-example",
    "status": "successful",
    "confirmedAmount": "126.750000",
    "outstandingAmount": "0"
  }
}
FieldTypeDescription
idstringImmutable business-event ID. It stays the same across delivery retries and replays.
typestringBusiness transition name.
schemaVersionstringEvent schema version, currently 1.
occurredAtstringTime the committed business event was created.
resourceVersionintegerMonotonic version for that resource. A lower version must not roll your state backward.
dataobjectAllow-listed payment, wallet, deposit, or transfer snapshot.

The Relay-Event-Id header equals the JSON body's id.

Verify signatures#

Relay computes an HMAC-SHA256 digest over:

text
<timestamp>.<exact raw request body>

The digest is lowercase hexadecimal. Verify the raw bytes before parsing JSON, use constant-time comparison, and reject timestamps outside a five-minute tolerance.

javascript
import crypto from "node:crypto";

export function verifyRelayWebhook(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.split(",").map((part) => part.trim());
  const timestampParts = parts.filter((part) => part.startsWith("t="));
  const signatures = parts
    .filter((part) => part.startsWith("v1="))
    .map((part) => part.slice(3));

  if (timestampParts.length !== 1 || signatures.length === 0) return false;
  const timestamp = timestampParts[0].slice(2);
  if (!/^\d+$/.test(timestamp)) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  return signatures.some((candidate) => {
    if (!/^[a-f0-9]{64}$/.test(candidate)) return false;
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(candidate, "hex"),
    );
  });
}

In Express, register a raw-body parser for the webhook route. In frameworks that parse JSON automatically, use the framework's documented raw-body hook. Re-stringifying parsed JSON changes whitespace or key representation and invalidates the signature.

Process safely#

Your handler should do only the work needed to authenticate and durably enqueue the event, then return a 2xx response.

javascript
app.post("/relay/webhooks", rawBodyMiddleware, async (request, response) => {
  const rawBody = request.body.toString("utf8");
  const valid = verifyRelayWebhook(
    rawBody,
    request.header("Relay-Signature") ?? "",
    process.env.RELAY_WEBHOOK_SECRET,
  );

  if (!valid) return response.status(400).send("invalid signature");

  const event = JSON.parse(rawBody);
  await events.insertIfAbsent(event.id, rawBody); // unique event.id
  response.status(204).end();
});

When processing asynchronously:

  1. deduplicate on event.id;
  2. keep the original event body for audit;
  3. compare resourceVersion before updating a local projection;
  4. treat incoming funding and outgoing settlement as separate facts; and
  5. retrieve the resource if an event cannot be applied confidently.

Event catalog#

Payment events#

Event typeMeaning
payment_intent.partially_fundedConfirmed eligible funds are below the target.
payment_intent.fundedThe required incoming amount is confirmed. Settlement can still be pending.
payment_intent.pending_verificationRelay has evidence that still requires verification.
payment_intent.awaiting_confirmationA detected transaction has not reached required confirmation.
payment_intent.settlement_submittedOutgoing settlement was submitted; this does not prove finality.
payment_intent.settledOutgoing settlement was verified on-chain.
payment_intent.settlement_failedOutgoing settlement needs failure handling; incoming funding remains accounted for.
payment_intent.settlement_review_requiredThe outgoing broadcast or accounting outcome requires reconciliation before another send.
payment_intent.completedThe completion workflow reached its completed state.
payment_intent.failedPayment processing failed.
payment_intent.cancelledThe payment was cancelled.

Payment data uses the same allow-listed schema as the payment object.

External wallet events#

  • wallet.deposit
  • wallet.withdrawal

The payload includes data.wallet and data.transaction, including the exact decimal amount, token, network, transaction hash, direction, and confirmation counts.

Customer wallet events#

  • customer_wallet.deposit_confirmed
  • customer_wallet.deposit_review_required
  • customer_wallet.merchant_settled
  • customer_wallet.merchant_review_required
  • customer_wallet.fee_settled
  • customer_wallet.fee_review_required
  • customer_wallet.transfer_review_required

Acknowledgments and retries#

Any HTTP status from 200 through 299 acknowledges the delivery. Relay uses a 10-second request timeout by default.

Relay retries:

  • network and timeout failures;
  • HTTP 408;
  • HTTP 429, honoring a bounded Retry-After value; and
  • HTTP 5xx.

Other 4xx responses are terminal. The default maximum is 10 attempts, using exponential backoff with jitter from approximately 30 seconds up to one hour. Delivery records are retained for 30 days by default. These operational values can be changed by Relay configuration, so do not build timing-sensitive logic around them.

A crash after your endpoint accepted an event but before Relay saved the acknowledgment can cause a retry. This is why deduplication is required even when your endpoint is reliable.

Rotation, replay, and delivery controls#

Dashboard users with the relevant permissions can disable endpoints, rotate a signing secret, inspect deliveries and attempts, and replay a retained delivery. Rotation has an overlap period in which Relay signs with both the current and previous secret. A replay preserves the original event ID and body while creating a new auditable delivery attempt.

Relay Finance APIServer-to-server financial infrastructure.