Wallet Infrastructure
Wallet Infrastructure
Notifications

Setup and Verification

Register notification endpoints and verify signed notifications.

Delivery flow
Wallet InfrastructureEventevent_type
signed POST
YouYour endpointx-signature
2xx
Wallet InfrastructureDelivered2xx
retry
Wallet InfrastructureRetried later5xx · 408 · 429

Register an endpoint

Create webhook endpoint with:

FieldValue
urlA public https address. Private and loopback addresses are refused.
eventsThe events to receive, by exact name. Wildcards such as deposit.* are refused.

The response includes the signing secret, shown only once.

Choosing the project

Endpoints receive the events of one project. By default that is the project of the API key you call with. To manage another project's endpoints, add ?projectId=<project id> to any webhook endpoint request. A key can name its own project, or any of your business's payment-links projects in the same environment. Other projects are managed in the dashboard.

Payment events go to the project the payment belongs to, so register endpoints on your payment-links project to receive them.

To add events to an endpoint, update it rather than registering the same URL again.

recovery.code and recovery.started are managed only in the dashboard, by an owner or admin.

Verify a notification

Each notification is a POST with these headers:

HeaderValue
x-signaturet=<timestamp>,v1=<signature>
x-timestampWhen it was sent, in Unix seconds.
x-event-typeThe event name.
x-idempotency-keyThe event's id. Use it to skip duplicates.

The signature is the hex HMAC-SHA256 of <timestamp>.<raw body> with your signing secret.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(parts.v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Responding and retries

Your responseWe
2xxMark it delivered.
5xx, 408, 429 or no responseRetry it later.
Any other 3xx or 4xxStop. No retry.

Respond quickly with 2xx, then process the event.

Delivery guarantees

GuaranteeDetail
At least onceAn event can be delivered more than once. Deduplicate on x-idempotency-key.
OrderNot guaranteed. Act on the status in each event, and read the API when unsure.
RetriesExponential backoff with random variation, starting at about 5 seconds and capped at 1 hour between attempts.
Retry limitUp to 15 attempts, then the delivery stops. The total retry window depends on scheduling and the random variation.
HistoryDelivery history is kept for 30 days.
Timestampx-timestamp is when it was sent. We recommend rejecting events more than 5 minutes old.

Sample consumer

import express from 'express';

const app = express();

app.post(
  '/webhooks/wallet',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody = req.body.toString('utf8');
    const signature = req.header('x-signature') ?? '';
    const sentAt = Number(req.header('x-timestamp'));
    const eventId = req.header('x-idempotency-key') ?? '';

    if (!verify(process.env.WEBHOOK_SECRET!, rawBody, signature))
      return res.sendStatus(401);
    if (Math.abs(Date.now() / 1000 - sentAt) > 300) return res.sendStatus(400);
    if (await alreadyProcessed(eventId)) return res.sendStatus(200);

    await saveForProcessing(eventId, JSON.parse(rawBody));
    res.sendStatus(200);
  },
);

verify is the function above. alreadyProcessed and saveForProcessing are your own storage: record the event first, respond, then process it from your store.

Errors

CodeStatusCause
validation_failed400An event name that does not exist, a wildcard such as deposit.*, a URL that is not a public https address, or projectId given more than once.
validation_failed409Another endpoint in the project already has the same URL and the same event.
forbidden403recovery.code or recovery.started was requested with an API key, or projectId names a project this key cannot manage.

On this page