# Webhooks

> Get notified when creators, invoices and payment runs change, and verify every delivery came from StrideHQ.

Webhooks tell your systems when something changes in StrideHQ, so you don't have to keep checking. When an event happens, StrideHQ sends an HTTPS `POST` to your endpoint with the details.

## Set up an endpoint

In **Settings > Developers > Webhooks**, or through the API with a key that has the `webhooks:manage` scope:

1. Add your endpoint's address. It must use HTTPS.
2. Choose which events it receives.
3. Copy the signing secret. It's shown once.

Sandbox and production endpoints are set up separately, and each endpoint has its own secret.

## Events

### Creators

| Event             | Sent when                            |
| ----------------- | ------------------------------------ |
| `creator.created` | A creator is added                   |
| `creator.updated` | A creator's details or status change |

### Invoices

| Event               | Sent when                                                        |
| ------------------- | ---------------------------------------------------------------- |
| `invoice.submitted` | An invoice is submitted, in the app, by email or through the API |
| `invoice.approved`  | An invoice is approved for payment                               |
| `invoice.rejected`  | An invoice is rejected                                           |
| `invoice.paid`      | The payment for an invoice reaches the creator                   |

### Payment runs

| Event                             | Sent when                                          |
| --------------------------------- | -------------------------------------------------- |
| `payment_run.prepared`            | A run is created and waiting for approval          |
| `payment_run.approved`            | A person approves a run                            |
| `payment_run.withdrawn`           | A run is withdrawn before approval                 |
| `payment_run.funded`              | Your money for the run has arrived in full         |
| `payment_run.completed`           | Every payment in the run has been made             |
| `payment_run.partially_completed` | Some payments in the run were made and some failed |
| `payment_run.failed`              | The run's payments failed                          |

### Payments

| Event            | Sent when                                                                       |
| ---------------- | ------------------------------------------------------------------------------- |
| `payment.failed` | A payment fails, including one returned by the creator's bank after it was sent |

### Testing

| Event       | Sent when                                      |
| ----------- | ---------------------------------------------- |
| `test.ping` | You send a test event from Settings or the API |

New event types may be added within v1. Ignore any you don't recognise.

## What a delivery looks like

```json
{
  "id": "evt_9a1c3e5b7d9f4b6a8c0e2a4c6e8a0b2d",
  "object": "event",
  "type": "payment_run.approved",
  "created_at": "2026-11-03T10:02:17Z",
  "api_version": "v1",
  "data": {
    "object": {
      "id": "run_3e5a7c9b1d2f4a6c8e0b2d4f6a8c0e1d",
      "object": "payment_run",
      "reference": "PR-STRIDE-26-053",
      "status": "awaiting_funds",
      "totals": [
        { "currency": "GBP", "amount": 1875749 }
      ]
    }
  }
}
```

`data.object` is the object as it is when the event is sent, in the same shape as the API returns it.

## Respond quickly

Return any `2xx` status within 10 seconds to confirm you've received it. Do any slow work afterwards, for example by adding the event to a queue.

## Verify the signature

Every delivery is signed, so you can check it came from StrideHQ and wasn't changed. The signature is in the `StrideHQ-Signature` header:

```
StrideHQ-Signature: t=1793786537,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

- `t` is when StrideHQ sent it, as a Unix timestamp.
- `v1` is an HMAC SHA-256 of the timestamp, a full stop and the raw request body, using your endpoint's signing secret.

To verify:

1. Take `t` and `v1` from the header.
2. Join `t`, a full stop and the raw request body, exactly as received.
3. Compute the HMAC SHA-256 of that, using your signing secret.
4. Compare it with `v1` using a constant-time comparison.
5. Reject the delivery if the timestamp is more than 5 minutes old.

Node.js:

```js
import crypto from "node:crypto";

// rawBody must be the unparsed body as a string or Buffer.
// In Express: app.post("/webhooks/stridehq", express.raw({ type: "application/json" }), ...)
function verify(rawBody, header, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (!parts.t || !parts.v1) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return (
    fresh &&
    expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  );
}
```

Python:

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(","))
    if "t" not in parts or "v1" not in parts:
        return False
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts["v1"])
```

Use the raw body. If your framework parses the JSON first, the signature won't match.

### Rotate a signing secret

In **Settings > Developers > Webhooks**, choose the endpoint and select **Roll secret**. The old secret keeps working for 24 hours, and during that time each delivery is signed with both, as two `v1` values in the header. Accept the delivery if either matches.

## Retries

If your endpoint doesn't return a `2xx`, or doesn't respond in time, StrideHQ tries again after:

1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then 24 hours.

If every delivery to an endpoint has failed for 3 days, the endpoint is turned off and your Owner is emailed. Turn it back on in **Settings > Developers > Webhooks** once it's fixed.

## Handle duplicates and order

- **The same event can arrive more than once.** Use the event `id` to skip ones you've already handled.
- **Events can arrive out of order.** Don't assume `payment_run.funded` arrives after `payment_run.approved`. Compare `updated_at` on the object, and ignore an event older than what you already have.
- **The object can change after the event.** `data.object` is a snapshot. Before acting on money, such as marking an invoice paid in your accounting software, get the latest version from the API.

## Delivery log

**Settings > Developers > Webhooks** shows every delivery for the last 30 days: the event, when it was sent, the status code your endpoint returned and each retry. You can resend any event from there.

## Test your endpoint

In the sandbox, use the [test helpers](https://developers.stridehq.ai/api-documentation/environments-and-sandbox#test-helpers.md) to move a payment run through each stage. Each step sends the same webhooks as in production.

## Next steps

- [Flow of funds](https://developers.stridehq.ai/flow-of-funds.md): what each payment run status means for your money.
- [API reference](https://developers.stridehq.ai/api-documentation/api-reference.md): every object that appears in `data.object`.
