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_id to 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 3xx is 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:

json
{
  "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:

  1. Step 1Echo it synchronously: respond 2xx with { "challenge": "<the challenge>" } (the raw challenge string as the whole body also works).
  2. Step 2Call back asynchronously: POST { "registration_id": "...", "challenge": "..." } as JSON to verify_url. A match returns { "ok": true, "status": "active" }; anything wrong or expired returns 404 with 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:

http
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: 1

The 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 any v1 matches any secret you hold; compare in constant time.
  • Secrets are prefixed whsec_ and shown once at registration or rotation.

Node#

javascript
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#

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 "", 204

Delivery 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-Attempt counts from 1.
  • Stable event_id. Every retry carries the same event_id (and X-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_at and the task's current state from get_task if order matters.

Dedupe sample#

javascript
// 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#

json
{
  "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#

EventWhenExtra data fields
task.approval_neededA task was created and is waiting for the user's approval in the Simp appapproval_url
task.status_changedA coarse transition: started (scheduled to in_progress) or cancelledfrom_status, to_status
task.input_neededA simp needs the user's decisiondecision: { decision_id, type: "input" or "approval" } (read the question with get_task)
task.participant_answeredA participant of a get_answer, send_update or follow_up task answered, acknowledged or declinedparticipant: { participant_id, label, state, answer, answered_at }
task.result_readyThe task's one result was publishedresult: { status, answered, total }, participants (each as above)
task.completedA call task achieved its objective (or left a voicemail: outcome: "voicemail_left")outcome
task.failedA call task ended without achieving its objectivefailure_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.