Webhook retries
A delivery gets up to four attempts. After the fourth failure it is marked failed. There is no later replay, and no endpoint to resend a delivery.
#Schedule
| Attempt | Sent after the prior failure | Earliest time after the match |
|---|---|---|
| 1 | At once | 0 to 3 s |
| 2 | 30 s | About 30 s |
| 3 | 5 min | About 5.5 min |
| 4 | 30 min | About 35.5 min |
A worker runs every 3 seconds and handles up to 20 due deliveries per pass, in id order. In busy periods, an attempt can start later than the table shows. Each attempt has a timeout of 6 seconds.
The delay is fixed and has no jitter.
#What counts as failure
| Outcome | Log error | Log code |
|---|---|---|
| Status 2xx | None. This is delivered. | The status |
| Status 3xx, 4xx or 5xx | non-2xx reply | The status |
| No response in 6 s | timeout | null |
| DNS failure, refused connection, TLS failure | connection failed | null |
| Host resolves to a private address | host resolves to a private address | null |
| The URL fails the rules at send time | The URL rule message | null |
All of these are retried, including a 4xx. There is no way for your endpoint to say "stop". Use 2xx to accept an event even when you choose to ignore it. A 401 from your signature check is retried too, which helps if your deployment was briefly misconfigured.
Your response body is not stored or read.
#What stays the same across attempts
| Item | Across attempts |
|---|---|
| Body bytes | Same. sent_at does not change. |
t in the signature | New on each attempt |
v1 in the signature | New, because t changed |
x-farbeam-rule | Same |
| URL | The rule's current URL at each attempt. If you PATCH the URL, pending retries go to the new one. |
| Secret | The rule's secret. It cannot change. |
#Reading the state of a delivery
curl -s "https://farbeamlabs.com/api/v1/rules/$RULE_ID/deliveries?limit=10" \
-H "Authorization: Bearer $FARBEAM_KEY"const res = await fetch(`https://farbeamlabs.com/api/v1/rules/${process.env.RULE_ID}/deliveries?limit=10`, {
headers: { Authorization: `Bearer ${process.env.FARBEAM_KEY}` },
});
for (const d of (await res.json()).data) {
console.log(d.ts, d.status, d.attempts, d.code, d.error);
}import os, requests
res = requests.get(
f"https://farbeamlabs.com/api/v1/rules/{os.environ['RULE_ID']}/deliveries",
params={"limit": 10},
headers={"Authorization": f"Bearer {os.environ['FARBEAM_KEY']}"},
timeout=30,
)
for d in res.json()["data"]:
print(d["ts"], d["status"], d["attempts"], d["code"], d["error"])status | attempts | Meaning |
|---|---|---|
pending | 0 | Queued, not tried yet |
pending | 1 to 3 | Failed so far, and a retry is scheduled. error holds the last failure. |
delivered | 1 to 4 | Done. error is null. |
failed | 4 | All attempts failed |
The log does not show the next attempt time, and it has no per-attempt history. It holds the last result.
#Interaction with limits
| Limit | Interaction |
|---|---|
| Hourly cap of 120 | Counted when a match is queued. Retries do not add to it. |
| Auto-off at 20 | Counts deliveries that ended as failed, not attempts. A delivery that succeeds on attempt 3 resets the count to 0. |
| Rule turned off | Pending retries still run. The off state only stops new matches. |
| Rule deleted | Pending retries are removed with it. |
| Service restart | Pending deliveries are stored in the database, so they survive a restart and continue. |
The time to auto-off depends on your match rate. At 20 matches or more in a short burst, a dead endpoint can reach the limit about 36 minutes after the first failure, once all 20 have run through their attempts.
#Designing for failure
- Answer fast, then process. Most timeouts come from doing work inside the handler.
- Make handling idempotent. Key on
rule_idanddata.uid. - Monitor
consecutive_failuresfrom List rules, and alert yourself above a few. Farbeam sends no notice when a rule turns off. - If your endpoint will be down for more than 36 minutes, expect lost deliveries. To recover, read List large transfers for that period. Your rule's logic can be repeated with Validate a rule for the newest 2,000 rows.
#Recovering a gap
// Fetch rows in a period and apply your own filter, after an outage.
const since = Date.parse("2026-11-04T18:00:00Z");
const rows = [];
let before = null;
while (true) {
const query = new URLSearchParams({ min_usd: "1000000", limit: "100" });
if (before) query.set("before", String(before));
const res = await fetch(`https://farbeamlabs.com/api/v1/transfers?${query}`);
const { data, meta } = await res.json();
rows.push(...data.filter((row) => Date.parse(row.ts) >= since));
const oldest = data[data.length - 1];
if (!oldest || Date.parse(oldest.ts) < since || meta.next_cursor === null) break;
before = meta.next_cursor;
}
console.log(rows.length);Rows stay available for 7 days. Because ts can be out of step with insert order by a few seconds, widen since a little.