Webhooks
Webhooks let Reeds tell you when something happens, so you don't have to poll. Each time a delivery changes status, a wallet is topped up or an invoice is issued, Reeds sends a signed POST to your server.
Set up an endpoint
- Open Dashboard → Developers and pick the mode (Test or Live) you are working in.
- Enter your endpoint URL. Test and Live each have their own URL.
- Copy the signing secret:
whsec_test_...for a Test endpoint,whsec_live_...for a Live one. Each endpoint has its own secret, separate from your client credentials, so rotating a client secret never breaks your webhooks.
Your URL must follow these rules:
- It must use HTTPS, with a host name (not an IP address,
localhostor an internal name such as*.local). - Reeds refuses a URL that resolves to a private, loopback or link-local address. This is checked when you save the URL and again on every send, and each send goes to the address Reeds checked.
- Reeds never follows redirects. If your URL answers with a 301 or 302, the attempt counts as failed. Give Reeds the final URL.
To receive Test-mode events while you develop, run a tunnelling tool that gives your local server a public HTTPS host name. Save that URL as your Test endpoint, then send POST /webhooks/test.
What Reeds sends
Reeds sends a POST with a JSON body and three headers.
| Header | Description |
|---|---|
Reeds-Event-Id | The event id, for example evt_4fQ7Hd2X. Use it to skip duplicates. |
Reeds-Event-Type | The event type, for example delivery.status_changed. |
Reeds-Signature | t=<unix time>,v1=<hex>. See Verify signatures. |
Every event has the same envelope:
{
"id": "evt_4fQ7Hd2X",
"type": "delivery.status_changed",
"created_at": "2026-10-26T13:05:02+01:00",
"livemode": true,
"data": {
"previous_status": "rider_assigned",
"delivery": { "id": "dlv_9Kd3Qx7M", "status": "picked_up", "...": "the full delivery object" }
}
}
livemode tells you which mode produced the event. See Events for every type and its data.
Reply fast
Reply with any 2xx status within 10 seconds. Anything else (a non-2xx status, a timeout, a connection error) counts as a failed attempt.
Verify the signature, save the event, return 200, and do the real work afterwards in a queue. Slow handlers cause timeouts, and timeouts cause retries.
Retries
If an attempt fails, Reeds retries on this schedule:
| Retry | Wait after the previous attempt |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |
Each wait also gets up to 15 seconds of random jitter, so a burst of failures doesn't come back as a burst. That is about 2 days in all: 8 attempts. After the last one the event is marked failed. Every attempt is signed afresh with the time it is sent, so a retry passes the five-minute timestamp check.
An endpoint that keeps failing for 3 days is disabled. Events still waiting to be sent are marked failed, and the account's Owners, Admins and Developers are emailed. Fix the endpoint, then save its URL again in Dashboard → Developers to turn it back on, and resend the failed events you need from the event log.
Reeds can also turn an endpoint off, for example while investigating a problem with it; your audit log says why. Nothing is sent to it then, not even a resend or a test event, and saving its URL doesn't turn it back on: contact support and Reeds turns it back on.
Event log and Resend
Dashboard → Developers shows the event log: every event, every attempt, the response your server gave. Use the Resend button to send an event again, for example after you fix a bug. A resend is one extra attempt, right away: it doesn't change the retry schedule, and it works on an endpoint disabled after failing, without turning it back on. The log keeps events for 30 days.
Duplicates and ordering
- Duplicates. An event can arrive more than once. Store the ids you have processed (
Reeds-Event-Id) and skip repeats. - Order. Events can arrive out of order. For delivery events, compare
data.delivery.updated_atwith what you already hold and ignore an older one. Or fetch the delivery withGET /deliveries/{id}.
Send a test event
POST /webhooks/test sends a test event to your endpoint, so you can check your receiver without creating a delivery. It is signed like a real event and has its own evt_ id. Send type to choose the event (delivery.status_changed by default).
curl -X POST https://api.getreeds.co/shipping/v1/webhooks/test \
-H "Authorization: Bearer $REEDS_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type": "wallet.funded"}'
The answer's data is {"event_id": "evt_...", "type": "wallet.funded", "sent": true}. sent is false when your endpoint didn't answer 2xx within 10 seconds. A test event appears in the event log, but it is sent once and never retried. With no endpoint saved for the mode, or a type that isn't an event type, you get 422 INVALID_REQUEST; while Reeds has turned the endpoint off, 409 ACTION_NOT_ALLOWED. You can send 10 test events a minute.
A minimal receiver
Each example verifies the signature on the raw body, replies quickly and hands the work to a queue. The verify helper is explained in Verify signatures.
- Express
- PHP
- Flask
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.REEDS_WEBHOOK_SECRET; // whsec_...
function verify(rawBody, header) {
const parts = header.split(',').map((p) => p.split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
return sigs.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
// express.raw keeps the body exactly as Reeds sent it.
app.post('/webhooks/reeds', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(raw, req.get('Reeds-Signature') || '')) return res.sendStatus(400);
const event = JSON.parse(raw);
queue.add('reeds-event', event); // your queue: process later, skip ids you've seen
res.sendStatus(200);
});
app.listen(3000);
<?php
$secret = getenv('REEDS_WEBHOOK_SECRET'); // whsec_...
function verify_reeds($raw, $header, $secret) {
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') $t = $v;
if ($k === 'v1') $sigs[] = $v;
}
if (!$t || abs(time() - (int)$t) > 300) return false;
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
foreach ($sigs as $s) {
if (hash_equals($expected, $s)) return true;
}
return false;
}
$raw = file_get_contents('php://input'); // the raw body
$header = $_SERVER['HTTP_REEDS_SIGNATURE'] ?? '';
if (!verify_reeds($raw, $header, $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
enqueue_event($event); // your queue: process later, skip ids you've seen
http_response_code(200);
import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["REEDS_WEBHOOK_SECRET"] # whsec_...
def verify(raw: bytes, header: str) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
sigs = [v for k, v in parts if k == "v1"]
if not t or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
@app.post("/webhooks/reeds")
def reeds_webhook():
raw = request.get_data() # the raw body
if not verify(raw, request.headers.get("Reeds-Signature", "")):
return "", 400
event = request.get_json(force=True)
enqueue_event(event) # your queue: process later, skip ids you've seen
return "", 200
Next steps
- Verify signatures in all four languages
- Events: every event type and payload
- Delivery statuses
- Sandbox: trigger real webhooks in Test mode