Webhooks
Register HTTPS endpoints to receive HMAC-signed events. Currently emits on batch terminal states; more event types coming as the platform grows.
Endpoints
| Method | Path | What it does |
|---|---|---|
| POST | /v1/webhooks |
Register a webhook. Secret returned only once in the create response. |
| GET | /v1/webhooks |
List your webhooks. last_delivery_at + last_error shown per row. |
| DELETE | /v1/webhooks/{webhook_id} |
Delete a webhook. In-flight retries for queued deliveries will give up. |
Supported events
| Event | Fires when |
|---|---|
batch.completed |
Batch transitions to completed |
batch.failed |
Batch transitions to failed |
Register a webhook
r = httpx.post("https://api.epithre.com/v1/webhooks",
headers={"Authorization": f"Bearer {EPITHRE_KEY}"},
json={
"url": "https://your-app.example.com/epithre-webhook",
"events": ["batch.completed", "batch.failed"],
"description": "Notify batch completion to my queue worker"
},
).json()
print(r["id"]) # wh-abc123...
print(r["secret"]) # whsec_... SHOWN ONCE. Store it. Used to verify signatures.
URL requirements
- Must be HTTPS.
- Private / loopback / reserved IPs are rejected (SSRF guard).
- DNS rebinding attacks against your own URL still possible; pin to a static IP in production if security-sensitive.
Delivery payload
Epithre POSTs JSON to your URL with these headers:
POST /your-webhook-path HTTP/1.1
Content-Type: application/json
Epithre-Event: batch.completed
Epithre-Signature: t=1778765156,v1=<hex_hmac_sha256>
User-Agent: Epithre-Webhooks/1.0
{"event":"batch.completed","created":1778765156,
"data":{"id":"batch-abc...","status":"completed","endpoint":"/v1/embeddings",
"request_counts":{"total":1000,"completed":1000,"failed":0},
"total_cost_idr":0.024}}
Verify signature
The signature is computed as HMAC-SHA256(secret, f"{t}.{raw_body}") in hex.
import hmac, hashlib, time
def verify_webhook(secret, signature_header, raw_body, max_age=300):
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > max_age:
return False # replay protection
signed = f"{t}.{raw_body.decode()}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Reject deliveries that don't verify. Always use hmac.compare_digest (not ==) to avoid timing attacks.
Retry policy
Non-2xx response or timeout (10s) triggers retry with exponential backoff:
| Attempt | Wait before |
|---|---|
| 1 | (immediate) |
| 2 | 5s |
| 3 | 30s |
| 4 | 5m |
| 5 | 30m |
| (give up) | after 2h |
After 5 failed attempts the delivery is marked giveup and skipped. The most recent error appears in the webhook's last_error field via GET /v1/webhooks.
Best practices
- Respond quickly: return 200 within a few seconds. Do heavy processing in a background queue.
- Idempotency: webhooks may be retried after a network blip even if you successfully processed. De-dupe by
data.id(the batch ID). - Signature verification: always. Without it, anyone can POST fake events.
- Replay protection: enforce
max_age(5 min suggested) on the signature timestamp. - Storage: keep webhook deliveries you receive for at least 7 days, indexed by
data.id, for debugging.
See also
- Batches reference - the events fire on batch state transitions.
- Best practices guide