Webhook replies, handshakes and further signature schemes
This is the detail behind Webhooks that need a reply and Handshakes: how the Twilio, Slack and SNS signatures are checked, how each handshake is answered, and how a reply is made, kept and limited.
- Twilio.
SignatureScheme.twilio:X-Twilio-Signature, the Base64 of the HMAC-SHA1 under the auth token (the connection's secret) of the URL Twilio requested followed by each form member's name and value, sorted by name and, for a name sent twice, by value. The URL ishttps://, the request'sHostand its path and query, tried with:443written and without, as Twilio's own libraries do; Twilio sends only to HTTPS and a proxy in front of the host ends the TLS. It signs no time, so no age is checked; a repeat is dropped by its id. The body is a form (400 invalid_requestotherwise), read as JSON of its members' text, a name sent twice a list, so the places and fields name form members (event_id: "CallSid"). - Slack.
SignatureScheme.slack, since no other scheme checks Slack's events:X-Slack-Signature: v0=<hex>, the HMAC-SHA-256 ofv0:<X-Slack-Request-Timestamp>:<body>under the signing secret, with the five-minute tolerance. - SNS.
SignatureScheme.sns: the message's ownSignature, RSA PKCS #1 v1.5 over the members SNS lists for itsType, with SHA-1 (SignatureVersion1) or SHA-256 (2), by the RSA key (2,048 bits or more) of the certificate at itsSigningCertURL, valid at the host's time; the chain isn't checked, since the certificate comes over HTTPS from AWS's own host. Any AWS account can have SNS sign a message from its own topic, so the connection'ssecret:is the topic's ARN, and a message from any other topic is refused before anything is fetched. The certificate is fetched only fromhttps://sns.<region>.amazonaws.com(or.amazonaws.com.cn), a.pempath with no port, credentials or query, by the host's guard for the runtime's own requests: HTTPS only, no redirect followed, connected only to the public address it checked (never loopback, private, link-local, shared, reserved or multicast, and an IPv4 address carried inside IPv6, by NAT64, 6to4 or Teredo, judged as itself), at most 16 KB within five seconds; each certificate is kept for a day. SNS signs the time it published and its retries keep it, so the five-minute tolerance holds only ahead of the host's clock: a message signed 30 days ago or more, as long as a received id is kept, is refused, so any replay within that is a repeat by itsMessageId. A certificate that can't be fetched is503 unavailable, so SNS sends it again; a host that reaches nothing answers so. TheMessage, when it holds JSON, is read as that JSON, so places reach inside it ("Message.type"). Anything but aNotificationcarries no event: answered204. - Handshakes.
handshake:namesWebhookHandshake.sns,.slackor.microsoft_graph(package.provider_invalidotherwise); SNS's and Slack's needSignatureScheme.snsand.slack, which sign them (package.provider_invalidotherwise). SNS'sSubscriptionConfirmation, signed and from the topic, is confirmed by aGETof itsSubscribeURLon an SNS host through the same guard (204when SNS took it,503otherwise). Slack'surl_verification, once signed, is answered200with{"challenge": ...}. Microsoft Graph's validation is the only request answered before a signature is looked for, since Graph signs none: aPOSTwhose query gives onevalidationTokenof at most 1,024 characters, none a control character, answered200with the token astext/plain; charset=utf-8andX-Content-Type-Options: nosniff(400 invalid_requestfor one that isn't). - Replies.
reply:in a webhook event a package declares names anObjectof the package's own (package.wire_invalidotherwise); on any other eventreplyis reserved (type.reserved_field_name), so it is never a field. The connection'sreplies:keys each event needing a reply by its name in lower_snake_case,incoming_call:, naming an action<domain>.<action>(any domain; the app'suseline for it, ason_first_sign_in:'s) that takes the one event and returns the reply type: a key no such event has isconnection.key_invalid, a value that isn't such an actionconnection.value_invalid, and an event needing a reply with noneconnection.key_missing. Subscribing to such an event issubscriber.event_invalid: it isn't kept in the outbox. - Answering. After the signature and the body are read, the event's
id is claimed in
jadpo_received_events(its newreplycolumn empty) in a statement of its own; the action runs as the system, reaching no tenant's records, in its own transaction, ason_first_sign_in:'s does; its reply is encoded with the reply type's codec on the provider's wire and sent200asapplication/json, and kept over the claim once the action has committed. A repeat that finds the reply is answered with it, one that finds a claim younger than a minute409 conflict("The webhook is still being answered."), and a claim older than that, left by a process that stopped, is taken over. A failure gives the claim back, so the provider's retry runs the action again: a declared failure answered as a route answers it,unavailable503, a fault500. Keeping the reply follows the action's commit rather than joining it, since the action may write another store: if that statement fails, a retry after the claim lapses runs the action again. - What a reply may show. What only
Access.publiclets the reply action in to ispolicy.system_grant_missingat itsreplies:entry. The reply goes back to whoever sent the webhook, so each field it carries, or that decided it, is one anyone at all may read (policy.output_field_unproved), a record the action created holding what it set. Whether records are there reaches the sender too: a reply counting or testing for records only the system may read, or a failure decided by it, is refused the same way, and the action's failure, answered to the sender as its reply is, is held to the same proof, what decides it and its client fields (Field restrictions).
Graph's own notification check, its
clientStatein the body, isn't a signature scheme yet. Twilio's TwiML replies wait for an XML byte format.
Markdown for agents: /docs/webhook-replies-handshakes-and-further-signature-schemes.md.Built from Jadpo main at a707efb.