Skip to main content

Delivery & reconciliation

Webhook delivery is at-least-once. We will deliver every signal — and under retry you may see the same signal more than once. Your receiver must be idempotent, and you should run the pull backstop so that any signal we couldn't push still reaches you.

At-least-once + retry/backoff

When we POST a signal we expect a fast 200. Anything else — a non-2xx, a timeout, a connection error — is a failed attempt, and we retry with backoff. Because a slow ack can succeed on our side after we've already given up waiting, the same signal_id can legitimately arrive twice.

Implication: never assume exactly-once. Ack fast (return 200 the moment the signature verifies) so a slow OMS round-trip doesn't trigger needless retries — see verify webhooks.

Dedupe on signal_id (idempotency)

signal_id is the stable, unique idempotency key. Record processed ids and drop repeats:

seen = set() # back this with Redis / a DB column with a UNIQUE constraint in production

def handle(signal):
sid = signal["signal_id"]
if sid in seen:
return # already processed — at-least-once means this WILL happen
if signal["is_test"]:
seen.add(sid)
return # HARD skip test fires
seen.add(sid)
route_to_oms(signal)

In production, enforce idempotency in storage (a UNIQUE constraint on signal_id, or a conditional write) so a duplicate that arrives on another worker or after a restart is still caught.

Per-attempt status taxonomy

On our side we log the outcome of every delivery attempt. If you compare notes with support, these are the values you'll see:

StatusMeaning
deliveredYour endpoint returned 2xx. Attempt succeeded.
http_<code>Your endpoint returned a non-2xx, e.g. http_401, http_500. We will retry.
errorTransport-level failure (timeout, DNS, connection refused). We will retry.
failedRetries exhausted — we stopped attempting this signal. Reconcile via the pull API.

A failed on our side is exactly the case the pull backstop exists for: the signal is still durably stored and retrievable by seq.

The pull backstop

The pull API is reconciliation, never the lead. Its job is to guarantee that a missed webhook is never a missed trade. Base URL:

https://signals.btcalpha.com.au

Auth header on every pull request:

X-API-Key: <key>
EndpointUse
GET /v1/signals/latestThe most recent signal — a quick "am I current?" check.
GET /v1/signals/history?since=<seq>All signals with seq greater than since — your gap filler.

The pattern: track last_seq, poll on a timer + after downtime

Persist the highest seq you have fully processed. Reconcile (a) on a timer and (b) immediately after any downtime — a deploy, a crash, a network outage:

import requests

BASE = "https://signals.btcalpha.com.au"
HEADERS = {"X-API-Key": "<key>"}

def reconcile(last_seq):
"""Fetch everything newer than last_seq and process in order."""
r = requests.get(f"{BASE}/v1/signals/history",
params={"since": last_seq}, headers=HEADERS, timeout=10)
r.raise_for_status()
rows = r.json()["data"] # rows with seq > last_seq, ascending
for sig in rows:
handle(sig) # SAME idempotent handler as the webhook path
last_seq = max(last_seq, sig["seq"])
return last_seq # persist this

Because handle() dedupes on signal_id, it's safe to run reconciliation even when the webhook already delivered everything — overlaps are dropped, gaps are filled. Run it:

  • On a timer (e.g. every minute) as a steady safety net.
  • On startup, immediately, so a restart can't leave a gap.
  • After any detected downtime in your receiver.

You can also use previous_signal_id as a live gap detector: if a webhook's previous_signal_id isn't a signal you've already processed, you missed one — trigger reconcile() now rather than waiting for the timer.

:::tip A missed webhook is never a missed trade The webhook gives you latency; the pull gives you completeness. With an idempotent handle() keyed on signal_id and a persisted last_seq, every gap closes itself and nothing is double-traded. :::