> ## Documentation Index
> Fetch the complete documentation index at: https://glide-9da73dea.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed kyc, account and transfer events. Verify X-Glide-Signature over the raw body, dedupe by event id, and return 2xx.

Glide pushes four event types to an HTTPS endpoint you register. Delivery is at least once, ordering is not guaranteed, and every attempt rechecks that the user's consent still covers the event.

## Register an endpoint

Ask Glide to add your URL to your partner's allowlist and configure the endpoint. Glide returns the `endpoint_id` and the signing secret **once**, and stores only encrypted ciphertext bound to that endpoint.

Each allowlisted URL gets its own independent secret, which limits how far a leak can reach. Rotation is also a Glide-side operation, so coordinate it with your deploy: attempts pending at the moment of rotation use the new secret, and attempts already in flight may still carry the previous one. Accept both secrets for a short window.

Requirements Glide enforces on your endpoint:

* HTTPS on port 443.
* Every DNS result must be a public address. The connection is pinned to the validated IP.
* No redirects are followed. A 3xx is a terminal failure, not a hop.
* DNS and request timeouts bound each attempt.

## Events

| Type                     | Scope required    | Fires when                             |
| ------------------------ | ----------------- | -------------------------------------- |
| `kyc.status_changed`     | `kyc:read`        | Verification status changes            |
| `us_account.provisioned` | `us_account:read` | A USD receiving account is provisioned |
| `transfer.settled`       | `transfers:read`  | A personal ledger transfer completes   |
| `transfer.failed`        | `transfers:read`  | A personal ledger transfer fails       |

Every event shares `id`, `type`, `user_id` and `occurred_at`, with a per-type `data` object.

```json theme={null}
{
  "id": "44444444-4444-4444-8444-444444444444",
  "type": "kyc.status_changed",
  "user_id": "11111111-1111-4111-8111-111111111111",
  "occurred_at": "2026-09-06T00:00:00.000Z",
  "data": { "status": "approved", "provider": "noah" }
}
```

```json theme={null}
{
  "id": "44444444-4444-4444-8444-444444444444",
  "type": "us_account.provisioned",
  "user_id": "11111111-1111-4111-8111-111111111111",
  "occurred_at": "2026-09-06T00:00:00.000Z",
  "data": {
    "account_id": "55555555-5555-4555-8555-555555555555",
    "currency": "USD",
    "provider": "noah"
  }
}
```

```json theme={null}
{
  "id": "44444444-4444-4444-8444-444444444444",
  "type": "transfer.settled",
  "user_id": "11111111-1111-4111-8111-111111111111",
  "occurred_at": "2026-09-06T00:00:00.000Z",
  "data": {
    "transfer_id": "22222222-2222-4222-8222-222222222222",
    "status": "completed",
    "amount_cents": "2500",
    "currency": "USD"
  }
}
```

```json theme={null}
{
  "id": "44444444-4444-4444-8444-444444444444",
  "type": "transfer.failed",
  "user_id": "11111111-1111-4111-8111-111111111111",
  "occurred_at": "2026-09-06T00:00:00.000Z",
  "data": {
    "transfer_id": "22222222-2222-4222-8222-222222222222",
    "status": "failed",
    "amount_cents": "2500",
    "currency": "USD"
  }
}
```

Events carry no bank details and no identity documents. When you need the current picture, call the scoped read endpoint rather than treating the event body as the record.

`transfer.settled` is how a payout you pushed reports back. Correlate on `data.transfer_id`, which is the same id the batch response returned for that item. Transfer events cover the same personal ledger population as the transfers read endpoint, so events also arrive for movements you did not cause.

## Headers

| Header              | Value                                   |
| ------------------- | --------------------------------------- |
| `Content-Type`      | `application/json`                      |
| `Idempotency-Key`   | The event `id`                          |
| `X-Glide-Signature` | `t=<unix-seconds>,v1=<hex HMAC-SHA256>` |

## Verify the signature

Compute HMAC-SHA256 over the exact string `${timestamp}.${rawRequestBody}` using your endpoint secret, then compare in constant time. Use the raw bytes of the body. Re-serializing parsed JSON will change whitespace or key order and break the signature.

```ts theme={null}
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.GLIDE_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;

function verifySignature(header: string | undefined, rawBody: Buffer): boolean {
  if (!header) return false;

  let timestamp = '';
  let signature = '';
  for (const part of header.split(',')) {
    const [key, value] = part.trim().split('=');
    if (key === 't') timestamp = value ?? '';
    if (key === 'v1') signature = value ?? '';
  }
  if (!timestamp || !signature) return false;

  // Reject stale or future-dated timestamps.
  const sent = Number(timestamp);
  if (!Number.isFinite(sent)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - sent) > TOLERANCE_SECONDS) return false;

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

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(signature, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body as bytes. Do not mount express.json on this path.
app.post(
  '/webhooks/glide',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    if (!verifySignature(req.header('X-Glide-Signature'), req.body)) {
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8')) as {
      id: string;
      type: string;
      user_id: string;
      occurred_at: string;
      data: Record<string, unknown>;
    };

    // Implement acceptEventOnce with one database transaction: insert the
    // unique event id AND a durable work item. A crash must not leave a
    // dedupe marker without queued work. Process the work item asynchronously.
    const isNew = await acceptEventOnce(event);
    if (!isNew) return res.status(200).send('duplicate');

    return res.status(200).send('ok');
  }
);

app.listen(3000);
```

Reject anything older or newer than a short tolerance. Five minutes is the recommended window. The timestamp and signature are regenerated on each retry, while the event `id` and payload stay identical, so tolerance protects against replay without breaking legitimate retries.

Dedupe on the event `id`, which is also the `Idempotency-Key` header, and do it before you apply side effects. Return 2xx only once you have durably accepted the event.

## Retries

The delivery worker drains up to 50 deliveries a minute, with durable leases so a crashed worker recovers rather than dropping events.

| Attempt | Delay after the previous one |
| ------- | ---------------------------- |
| 2       | 30 seconds                   |
| 3       | 60 seconds                   |
| 4       | 120 seconds                  |
| 5       | 240 seconds                  |
| 6       | 480 seconds                  |
| 7       | 960 seconds                  |
| 8       | 1920 seconds                 |

There are at most eight attempts, counting attempts interrupted by a worker restart. The cron cadence can add up to a minute to each delay.

Network errors, 408, 429 and 5xx are retried. Every other non-2xx response, redirects included, is a terminal failure. If you need Glide to back off, return 429 or a 5xx rather than a 4xx.

Delivery is at least once, so your receiver may accept an event before Glide records the success. Design for a repeat.

## Consent binding

Before every attempt, the worker rechecks your partner status, the endpoint allowlist and the user's grant. Only active grants that carry the event's scope, delivered to an active endpoint on your exact URL allowlist, receive events.

An opaque consent version binds a queued event to the exact grant that authorized it, including a replacement grant made in the same instant. If the user revokes or replaces their grant, queued events are cancelled, and re-granting does not release events queued under the prior consent. A request already in flight cannot be recalled.

There is no historical backfill when you register an endpoint. Changes that do not move a relevant status emit nothing.

## Ordering

Ordering is not guaranteed. Two events for the same user can arrive out of sequence, and an event can beat the API response that caused it. When order matters, fetch current state from the read endpoints instead of folding events into a state machine.

The common case is a `transfer.settled` arriving before your own batch response has been saved. Hold the event, retry the correlation on a backoff, and alert a human if it stays unmatched. Do not discard it, and do not assume an unmatched transfer id is yours: a user may have authorized other partners who share the same ledger view.

## What Glide keeps

Delivery and attempt records retain outcomes and timestamps. Remote response bodies are not stored. Ask Glide for your delivery and attempt history when you need to reconcile.
