Events & webhooks
Account event feed (GET /api/events) and outgoing signed webhooks — the triggers behind Zapier, Make, n8n and any automation.
Events & webhooks
Every meaningful state change in your account produces an event: a job finishes, a pipeline completes, a monitoring run computes its diff. Two ways to consume them:
- Polling —
GET /api/events, a cursor-based feed. Simple, stateless, works everywhere. - Webhooks — outsend POSTs each event to your HTTPS endpoint, signed with HMAC-SHA256, with automatic retries.
Both are scoped to the authenticated account and work with API keys as well as session cookies.
Event types
| Type | Emitted when |
|---|---|
job.completed |
A job reaches done |
job.failed |
A job reaches failed |
job.cancelled |
A job is cancelled |
pipeline.completed |
A pipeline reaches done |
pipeline.failed |
A pipeline reaches failed |
pipeline.cancelled |
A pipeline is cancelled |
veille.run_completed |
A monitoring run finishes computing its diff |
Events are emitted within a few seconds of the state change and retained with a 7-day detection window.
Event shape
{
"id": 42,
"type": "job.completed",
"created_at": "2026-08-07 09:12:00",
"data": {
"id": "a1b2c3…",
"job_type": "scrap",
"status": "done",
"queries": ["plumber"],
"zones": ["Lyon"],
"results_count": 1250,
"error_count": 0,
"ef_cost": 0.18,
"created_at": "2026-08-07 08:55:12",
"completed_at": "2026-08-07 09:11:58",
"error_message": null,
"pipeline_id": null,
"source_job_id": null,
"download_url": "https://outsend.xyz/api/jobs/a1b2c3…/download"
}
}
data for pipeline.* events carries {id, name, status, created_at, completed_at}. For veille.run_completed it carries {run_id, veille_id, veille_name, job_id, is_baseline, total_count, new_count, removed_count, modified_count, computed_at}.
GET /api/events
Cursor-based polling. Auth: session cookie or API key.
| Query param | Type | Notes |
|---|---|---|
since_id |
int ≥ 0 | Only return events with id strictly greater. Default 0. |
types |
string | CSV filter, e.g. job.completed,pipeline.completed. Unknown types → 422. |
limit |
int 1–100 | Default 50. |
order |
asc | desc |
Default asc (chronological, for catching up). desc returns the most recent events first — use it to show representative samples in a UI. |
curl -s "https://outsend.xyz/api/events?since_id=0&types=job.completed" \
-H "Authorization: Bearer osk_..."
Response — 200 OK
{
"events": [ { "id": 42, "type": "job.completed", "created_at": "…", "data": { } } ],
"last_id": 42,
"head_id": 57
}
Two cursors, do not confuse them:
last_id— id of the last event on this page. Pass it back assince_idto catch up. Each event is then seen exactly once.head_id— id of the most recent event in the account for this filter, whatever page you asked for. This is what you store when you want to start "from now on" without replaying history.
The trap: since the default order is ascending, ?limit=1 returns the oldest event, so its last_id is not the head. A connector that stored it as its starting cursor would replay the whole account history on its next poll. Use head_id.
Fields are guaranteed by the current job.* contract: an event emitted before a field existed is returned with that field set to null, never absent.
POST /api/webhooks
Registers an endpoint. Auth: session cookie or API key. Maximum 5 active endpoints per account.
| Field | Type | Notes |
|---|---|---|
url |
string | https:// only, public host. Internal hosts, private IPs and outsend itself are refused (400). |
events |
string[] | At least one type from the table above. Unknown types → 422. |
secret |
string, optional | 16–128 chars. Generated (whsec_…) if omitted. |
curl -s -X POST https://outsend.xyz/api/webhooks \
-H "Authorization: Bearer osk_..." -H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/outsend", "events": ["job.completed"]}'
Response — 201 Created
{ "id": 7, "url": "https://example.com/hooks/outsend", "events": ["job.completed"], "secret": "whsec_…", "is_active": true, "consecutive_failures": 0, "created_at": "…", "disabled_at": null }
The secret is returned only here. Store it to verify signatures.
GET /api/webhooks
Lists the account's endpoints — without secrets. Response: { "webhooks": [ … ] }.
GET /api/webhooks/{id}/deliveries
Last 20 delivery attempts for the endpoint (status, attempts, HTTP code, error) — the first place to look when debugging an integration. 404 if the endpoint is not yours.
DELETE /api/webhooks/{id}
Deletes the endpoint (hard delete — this is what n8n does when a workflow is deactivated). Response: 204.
Delivery contract
Each event is POSTed as JSON to your URL:
| Header | Content |
|---|---|
Content-Type |
application/json |
User-Agent |
outsend-webhooks/1.0 |
X-Outsend-Event |
Event type, e.g. job.completed |
X-Outsend-Delivery |
Delivery id (unique per endpoint × event) |
X-Outsend-Signature |
sha256=<hex> — HMAC-SHA256 of the raw body with your secret |
Respond with any 2xx within 10 seconds. Anything else counts as a failure.
Verifying the signature
import hmac, hashlib
def is_valid(secret: str, raw_body: bytes, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header, expected)
Compute the HMAC over the raw request body, before any JSON parsing.
Retries
Failed deliveries are retried with backoff: 1 min → 5 min → 30 min → 2 h → 6 h, then the delivery is abandoned (gave_up). After 20 consecutive failures the endpoint is disabled (is_active: false, disabled_at set) — re-create it once your receiver is fixed.
What's next
- Authentication — create an API key
- Jobs — the objects behind
job.*events - Veille — monitoring runs behind
veille.run_completed