Webhooks: signed task events
Receive task.approval_needed, task.status_changed, task.input_needed, task.participant_answered, task.result_ready, task.completed and task.failed as signed HTTPS POSTs: registration, ownership handshake, HMAC signature verification in Node and Python, at-least-once delivery, retries, dead-lettering and the event catalogue.
Polling get_task works everywhere, but an automation platform wants to be told. Register an HTTPS endpoint and Simp POSTs a signed event whenever one of the user's tasks changes in a way worth knowing about. Webhooks are a developer channel, gated by registration and ownership verification - not by the user's push preferences.
Registering a webhook#
There is one registration per (user, connected app). Two ways to create it:
- In the Simp app: Connected apps, pick the connection, add a webhook URL. The user sees the signing secret once and can rotate the secret, disable or re-enable it, re-send the challenge, or delete it from the same screen.
- A default for your client: a client can carry a default webhook URL that Simp sets on its record; each user's consent then creates their own registration at that URL. Ask through your usual Simp contact. You still complete the handshake per registration, and each registration has its own secret - so you must be able to map a
registration_idto a user on your side (the handshake POST is where you learn it).
URL rules (SSRF defence)#
The URL is checked at registration and re-checked on every delivery, and the connection is made to the resolved IP with the hostname kept for TLS:
https://only, on the standard port, no credentials in the URL.- A public hostname or public IP. Rejected: loopback, RFC 1918, link-local (including cloud metadata addresses), CGNAT, multicast, reserved and documentation ranges, IPv4-mapped and NAT64 forms of any of those, and hostnames such as
localhost,*.internal,*.local. - The hostname must resolve, and every record it resolves to must be public. A hostname that later re-points at a private address fails at delivery time (DNS rebinding is caught).
- Redirects are not followed: a
3xxis a failed delivery. Each request times out after 10 seconds.
Ownership handshake#
A new registration is pending_verification and receives nothing until the endpoint proves it is yours. Simp POSTs a signed challenge:
{
"type": "simp.webhook.verify",
"registration_id": "4d2f7a0e-0000-4000-8000-000000000000",
"challenge": "Zk3q2v9bQ8xJ4LwT1nR7yH6sP0dC5aE2",
"verify_url": "https://voice.simpmobile.com/api/webhooks/verify",
"expires_in_seconds": 300
}Prove ownership in either of two ways within 5 minutes:
Echo it synchronously: respond 2xxwith{ "challenge": "<the challenge>" }(the raw challenge string as the whole body also works).
Call back asynchronously: POST{ "registration_id": "...", "challenge": "..." }as JSON toverify_url. A match returns{ "ok": true, "status": "active" }; anything wrong or expired returns404with no hint which half was wrong.
The challenge request carries X-Simp-Event: simp.webhook.verify and is signed with the registration's secret like every other delivery, so one handler can serve both. A challenge that expires can be re-sent from Connected apps.
Verifying signatures#
Every delivery carries:
POST /simp/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Simp-Webhooks/1.0 (+/developers/webhooks)
X-Simp-Signature: t=1755600000,v1=5f1d...e3a9
X-Simp-Event: task.result_ready
X-Simp-Event-Id: 0b7c2a6e-0000-4000-8000-000000000000
X-Simp-Delivery-Attempt: 1The signature is HMAC-SHA256(secret, t + "." + rawBody), hex-encoded, over the raw request body bytes exactly as received. Rules:
- Reject if
|now - t|is more than 5 minutes (replay protection). - During a secret rotation the header carries two
v1=entries (new secret first, then the previous one) for 24 hours. Accept if anyv1matches any secret you hold; compare in constant time. - Secrets are prefixed
whsec_and shown once at registration or rotation.
Node#
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_SKEW_SECONDS = 300;
export function verifySimpSignature({ header, rawBody, secrets, nowSeconds }) {
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
if (!header) return false;
const fields = header.split(',').map((f) => f.trim());
const t = Number(fields.find((f) => f.startsWith('t='))?.slice(2));
const v1s = fields.filter((f) => f.startsWith('v1=')).map((f) => f.slice(3));
if (!Number.isInteger(t) || v1s.length === 0) return false;
if (Math.abs(now - t) > MAX_SKEW_SECONDS) return false;
for (const secret of secrets) {
const hex = createHmac('sha256', secret).update(t + '.' + rawBody, 'utf8').digest('hex');
const expected = Buffer.from(hex);
for (const candidate of v1s) {
const got = Buffer.from(candidate);
if (got.length === expected.length && timingSafeEqual(got, expected)) return true;
}
}
return false;
}
// Express example: keep the RAW body - do not JSON-parse before verifying.
app.post('/simp/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const ok = verifySimpSignature({
header: req.get('X-Simp-Signature'),
rawBody,
// Current secret first; keep the previous one for 24h after a rotation.
secrets: [process.env.SIMP_WEBHOOK_SECRET, process.env.SIMP_WEBHOOK_SECRET_PREV].filter(Boolean),
});
if (!ok) return res.status(401).end();
const event = JSON.parse(rawBody);
if (event.type === 'simp.webhook.verify') return res.json({ challenge: event.challenge });
handleSimpEvent(event); // see dedupe below
res.status(204).end();
});Python#
import hmac, hashlib, time
MAX_SKEW_SECONDS = 300
def verify_simp_signature(header: str | None, raw_body: bytes, secrets: list[str], now: int | None = None) -> bool:
if not header:
return False
fields = [f.strip() for f in header.split(",")]
t_field = next((f for f in fields if f.startswith("t=")), None)
v1s = [f[3:] for f in fields if f.startswith("v1=")]
if t_field is None or not v1s or not t_field[2:].isdigit():
return False
t = int(t_field[2:])
if abs((now or int(time.time())) - t) > MAX_SKEW_SECONDS:
return False
for secret in secrets:
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(candidate, expected) for candidate in v1s):
return True
return False
# Flask example - read the RAW body before any JSON parsing.
@app.post("/simp/webhooks")
def simp_webhooks():
raw = request.get_data()
if not verify_simp_signature(request.headers.get("X-Simp-Signature"), raw, SECRETS):
return "", 401
event = json.loads(raw)
if event.get("type") == "simp.webhook.verify":
return jsonify(challenge=event["challenge"])
handle_simp_event(event)
return "", 204Delivery guarantees#
- At least once. A delivery counts as successful on any
2xx. Anything else - a non-2xx status, a redirect, a timeout, a connection error - is retried on the ladder 30s / 2m / 10m / 30m / 2h.X-Simp-Delivery-Attemptcounts from 1. - Stable
event_id. Every retry carries the sameevent_id(andX-Simp-Event-Id). It is your idempotency key: dedupe on it. - Dead-letter after 5 attempts. The event is parked, the registration is marked failing, and the user sees that under Connected apps.
- Auto-disable after 72 hours of sustained failure, with a push to the user. Re-enable from Connected apps once your endpoint is healthy.
- Respond fast: do your work after acknowledging, or at least within the 10-second timeout.
- Events are not strictly ordered. Use
occurred_atand the task's current state fromget_taskif order matters.
Dedupe sample#
// Any store with an atomic "insert if absent" works. Keep ids for at least 24h (the retry ladder spans ~3h).
async function handleSimpEvent(event) {
const fresh = await redis.set(`simp:evt:${event.event_id}`, '1', { NX: true, EX: 60 * 60 * 24 });
if (!fresh) return; // duplicate delivery - already handled
switch (event.event_type) {
case 'task.result_ready':
await onResult(event.data.task_id); // then read the result with get_task over MCP
break;
case 'task.input_needed':
await askTheUser(event.data.task_id, event.data.decision.decision_id); // get_task for the question, then respond_to_decision
break;
// ...
}
}Event envelope#
{
"event_id": "0b7c2a6e-0000-4000-8000-000000000000",
"event_type": "task.participant_answered",
"api_version": "v2",
"occurred_at": "2026-10-05T10:42:11.318Z",
"data": {
"task_id": "6f1c0a1e-0000-4000-8000-000000000000",
"kind": "get_answer",
"status": "in_progress",
"current_state": "in_progress",
"title": "Ask Anna",
"headline": null,
"contact_label": "Anna",
"persona_name": null,
"created_by_client_id": "https://chatgpt.com/oauth/client.json",
"participant": {
"participant_id": "2b9d5c11-0000-4000-8000-000000000000",
"label": "Anna",
"state": "answered",
"answer": { "option_id": "o1" },
"answered_at": "2026-10-05T10:42:10.902Z"
}
}
}The payload rule#
Payloads carry ids, labels, states and option ids only. Never an About You value, never a transcript or a call summary, never the question a simp asked, never a typed answer, never a route, and never a phone number beyond the task's own contact label. A typed answer arrives as { "has_text": true }; a decline as { "declined": true }. Fetch everything else with get_task over MCP, where your integration's tasks:read scope applies and call content arrives delimiter-wrapped. A CI check (webhook-payload:check) builds every event from rows carrying every withheld field and fails if any of them leaves.
Every registration the user holds receives the user's task events, whichever app created the task; filter on created_by_client_id if you only want your own. api_version is v2 since 2026-10-05: v1 events carried a summary, which is no longer sent.
Event catalogue#
| Event | When | Extra data fields |
|---|---|---|
task.approval_needed | A task was created and is waiting for the user's approval in the Simp app | approval_url |
task.status_changed | A coarse transition: started (scheduled to in_progress) or cancelled | from_status, to_status |
task.input_needed | A simp needs the user's decision | decision: { decision_id, type: "input" or "approval" } (read the question with get_task) |
task.participant_answered | A participant of a get_answer, send_update or follow_up task answered, acknowledged or declined | participant: { participant_id, label, state, answer, answered_at } |
task.result_ready | The task's one result was published | result: { status, answered, total }, participants (each as above) |
task.completed | A call task achieved its objective (or left a voicemail: outcome: "voicemail_left") | outcome |
task.failed | A call task ended without achieving its objective | failure_reason |
Common data fields on every event: task_id, kind, status, current_state, title, headline, contact_label, persona_name, created_by_client_id. Events about a call task also carry scheduled_for and series_id.
Secret rotation#
Rotate from Connected apps. The new secret becomes current immediately; the previous one keeps signing (as a second v1= entry) for 24 hours so you can roll it out without dropping deliveries. Hold both during that window, then drop the old one.
Testing#
The sandbox produces real task.approval_needed, task.status_changed, task.input_needed (book for six or mention an allergy) and task.completed events for a call task, so you can exercise your receiver end to end without calling anyone real. A local receiver is not reachable (private addresses are rejected); use a public tunnel with a valid certificate during development.
