> ## Documentation Index
> Fetch the complete documentation index at: https://docs.streamlinedpayments.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get an HTTPS POST when something changes, instead of polling for it.

Register an HTTPS endpoint and we POST an event to it when something changes, rather than you polling for it. Webhooks are enabled per account; ask us to switch them on.

## Register an endpoint

Send the URL and the events you want. Use `["*"]` to receive every event type, including ones added later.

```bash theme={null}
curl https://api.staging.streamlinedpayments.com/api/v1/webhook_endpoints \
  -X POST \
  -H "X-App-Id: $STREAMLINED_APP_ID" \
  -H "X-Api-Key: $STREAMLINED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.example.com/webhooks/streamlined",
    "events": ["deposit_link.paid", "deposit_link.payment_failed"],
    "description": "ERP deposit sync"
  }'
```

> [!WARNING]
>
> The response includes the endpoint's signing `secret`. You only see it here and when you rotate it, so store it now. You need it to [verify signatures](#verifying-the-signature).

Each account can have up to 10 endpoints. To check your endpoint end to end, send a `ping` with `POST /webhook_endpoints/{uuid}/test`.

## Events

Every change to a deposit link's status sends one, so if you act on `paid` you also hear when a paid link is undone.

| Type | When |
| - | - |
| `deposit_link.processing` | The customer confirmed a bank debit. It settles in 2 to 4 business days. |
| `deposit_link.paid` | The money arrived: a card charge, a bank debit that landed, or a wire, ACH credit or cheque attached to the link. |
| `deposit_link.payment_failed` | A bank debit was returned. The link has reopened if it still had time, or expired. |
| `deposit_link.payment_removed` | A payment attached to the link by hand was taken off again, usually because it was the wrong one. The link is open, and an earlier `paid` no longer holds. |
| `deposit_link.refunded` | The money went back to the customer. |
| `deposit_link.canceled` | The link was canceled, in the Streamlined app or through this API. |
| `deposit_link.expired` | The link lapsed unpaid. Sent by a daily sweep, so up to a day after `expires_at`. |
| `ping` | Sent only by `POST /webhook_endpoints/{uuid}/test`. |

`GET /webhook_endpoints/event_types` returns the current list.

## What we send

A `POST` with a JSON body:

```json theme={null}
{
  "id": "evt_5f0c2b9e8d7a4c1b9e3f6a2d4c8b7e1f",
  "type": "deposit_link.paid",
  "created_at": "2026-09-10T16:04:11Z",
  "livemode": true,
  "data": { "object": { "uuid": "3b8e5f21-...", "status": "paid", "...": "..." } }
}
```

`data.object` is the object exactly as the matching `GET` returns it at the moment of the change. For deposit link events that is a `DepositLink`. `livemode` is false for events from the sandbox.

Headers:

* `Streamlined-Signature`: see below
* `Streamlined-Event-Id`: the `id` above
* `Streamlined-Event-Type`: the `type` above
* `Streamlined-Delivery-Id`: this delivery. Unique per endpoint per event.

## Verifying the signature

`Streamlined-Signature` looks like `t=1757520251,v1=5257a869...`. Compute an HMAC-SHA256 of `<t>.<raw request body>` with your endpoint's secret, hex-encode it, and compare it with `v1` in constant time. Reject the request if `t` is more than five minutes from your clock, which stops a captured request being replayed later.

Use the raw body bytes you received, before any JSON parsing.

```ruby Ruby theme={null}
def verified?(secret, raw_body, header)
  parts = header.split(',').filter_map { |p| p.split('=', 2) if p.include?('=') }.to_h
  t = Integer(parts['t'], exception: false)
  return false if t.nil? || parts['v1'].nil? || (Time.now.to_i - t).abs > 300

  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}")
  Rack::Utils.secure_compare(expected, parts['v1'])
end
```

```javascript Node.js theme={null}
const crypto = require('crypto');

function verified(secret, rawBody, header) {
  const parts = Object.fromEntries(
    header.split(',').filter(p => p.includes('=')).map(p => p.split('=', 2))
  );
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Responding, retries and failures

Start answering within 10 seconds and finish within 20. Return any `2xx` and do the work afterwards; no response in time counts as a failure. A `2xx` that arrives in time is a delivery even if the rest of your response body is slow, and we only read the first 2 KB of it. Anything else, including a redirect, is retried: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, eight attempts over about 45 hours. `GET /webhook_endpoints/{uuid}/deliveries` shows every delivery's latest outcome, and one that gave up can be sent again with `POST .../deliveries/{delivery_uuid}/retry`.

If five deliveries in a row give up without one getting through in between, the endpoint switches itself off (`status: disabled`, with a `disabled_reason`). Events that happen while it is off are not queued for it. Turn it back on with `PATCH` and `status: enabled`.

## Guarantees

* **At least once.** The same event can arrive more than once. Deduplicate on `id`, and acknowledge a repeat with a `2xx`.
* **Not in order.** Retries mean a later event can arrive first. Compare `created_at`, or fetch the object before acting on an irreversible change.
* **HTTPS to a public host.** We will not deliver to a private, loopback or link-local address, and we do not follow redirects.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.