Skip to content
API pagesWebhooks

API documentation

Webhooks

Two routes where something outside Syllevo pushes in: the email ingestion door and the Stripe billing door.

Neither of these is a route you call. They are documented because every route in the contract is documented, and because knowing what they do explains why some data appears in Syllevo a few seconds after it happens rather than instantly.

Syllevo does not send webhooks

There is no outbound webhook in this API. Syllevo will not call a URL of yours when a document arrives or an invoice is matched.

Outbound webhooks are planned. Nothing is built yet, so there is no date and no promise about what the payloads will look like. Until they ship, the way to notice what is new is to poll the list routes with a date filter. See Documents and Invoices.

Routes

Method Path Authenticated by
POST /v1/ingestion/email An HMAC signature over the raw body
POST /v1/billing/webhooks/stripe Stripe's own signature

Both are anonymous in the sense that no bearer token and no API key will help. The signature is the credential, and a request that fails verification is refused without being told why.

Both are carved out of the global rate limiter and carry their own policies, so a burst of Stripe retries after an outage cannot spend anybody else's budget and a busy sign-in minute cannot stall a subscription change.

Both answer 404 when the door is switched off, rather than 403 or 503. A door that is not there tells a scanner nothing about a door that is.

POST /v1/ingestion/email

Where forwarded invoices arrive. A Cloudflare Email Worker posts the raw MIME of every message delivered to Syllevo's ingestion domain here, signed with a shared secret.

This is the machinery behind the forwarding addresses on Sources. A client forwards an invoice, the edge worker signs the message, and this route stores and queues it.

Headers:

Header Carries
X-Syllevo-Signature The HMAC-SHA256, in hex, over the signing material below
X-Syllevo-Signature-Version Which signing scheme produced it. The edge worker sends 3
X-Syllevo-Timestamp When it was signed
X-Syllevo-Recipient The address it was delivered to
X-Syllevo-Sender Who sent it
X-Syllevo-Message-Id The message id
X-Syllevo-Spf The SPF verdict at the edge. Believed only under version 3
X-Syllevo-Dkim The DKIM verdict. Believed only under version 3
X-Syllevo-Dmarc The DMARC verdict. Believed only under version 3

Version 3 signs seven parts in this order: the timestamp, the recipient, the sender, the message id, and the SPF, DKIM, and DMARC verdicts. Each part is written as its length in UTF-8 bytes, a colon, the part itself, and a comma. The raw body bytes follow the last comma:

{length}:{timestamp},{length}:{recipient},{length}:{sender},{length}:{messageId},{length}:{spf},{length}:{dkim},{length}:{dmarc},{body}

Under version 3, every header after the timestamp is percent-encoded UTF-8, the same thing JavaScript's encodeURIComponent writes. A header can only carry plain bytes, so a sender like ștefan@firma.ro travels as %C8%99tefan%40firma.ro. Every value is encoded, plain ASCII included, so @ is always %40 and a literal % is always %25. The signature covers the encoded values, exactly as they go on the wire, and the API decodes them only after the signature checks out.

For timestamp 1735689600, recipient k7f2mq9x4tzr@ingestion.syllevo.com, sender furnizor@example.com, message id abc123, SPF pass, no DKIM verdict, and DMARC pass, the recipient goes on the wire as k7f2mq9x4tzr%40ingestion.syllevo.com and the sender as furnizor%40example.com. The rest encode as themselves, and the bytes before the body are:

10:1735689600,36:k7f2mq9x4tzr%40ingestion.syllevo.com,22:furnizor%40example.com,6:abc123,4:pass,0:,4:pass,

With the secret ingestion-signing-secret-32-chars and the body Authentication-Results: mx.cloudflare.net; spf=pass; dmarc=pass\r\n\r\nbody, the signature is 1a91af483668062787c4dbf5b1df4bf2d9ffbec46b9c1ceaf3955da26c301ac4.

A header that is absent counts as an empty part, 0:,. Lengths are written in plain decimal with no leading zeros. Headers are signed exactly as they go on the wire, with no trimming, no change of case, and no decoding.

The lengths are there because the sender chooses the message id. With the parts joined by a plain separator, a message id ending in .pass.pass.pass could be cut short on a captured request and its tail read as three passing verdicts, under the same signature. A length says exactly where each part ends, so the signed bytes split into parts only one way.

Versions 1 and 2 are still accepted, but they do not cover the verdicts, so under them the API records every verdict as unknown, whatever the headers say. The minimum version can be raised so that only version 3 passes. After that, an older signature gets the same 401 as any other failure.

The body is raw message/rfc822, up to 30 MB. The timestamp tolerance is 5 minutes, so a captured request cannot be replayed hours later.

Answers 202:

{
  "ingestionMessageId": "88888888-8888-4888-8888-888888888888",
  "status": "Received",
  "replayed": false
}
Field Type Means
ingestionMessageId uuid The message. It is in the logs under this id
status string Always Received from this route
replayed boolean True when this exact message had already been taken

202, not 201 and not 200. The message is stored and queued; whether it becomes a document depends on parsing that has not happened yet, and saying "created" would claim more than is true.

status is always Received here because that is all this route knows. The later states, Parsed, Quarantined, Rejected, and one for mail that parsed cleanly and carried nothing fileable, are written by the Worker on the other side of the queue.

A message no address claimed is still a 202. It is quarantined, held with a retention window, and counted, because a misdirected invoice is a support case rather than garbage. Nothing that arrives here is dropped.

Refusals:

Status Code Means
404 not_found Ingestion is switched off on this deployment
401 ingestion_rejected The signature did not verify. One uniform refusal
400 validation_failed Signed under version 3, but a header is not valid percent-encoded UTF-8
413 payload_too_large Over 30 MB

The 401 is the same body for every cause: wrong signature, stale timestamp, missing header, unknown version. The reason goes to the log and nowhere else.

The 400 only follows a signature that checked out, so it means the edge worker wrote a header it should not have: a % without two hex digits after it, a character outside ASCII, bytes that are not UTF-8, or a control character once decoded. The log names the header and the problem, never the value. The edge worker retries it like any other refusal, so the message waits rather than being lost.

This route verifies and enqueues. It does not parse. Taking hostile MIME apart happens in the Worker, deliberately, so the most dangerous work in the system does not happen in the least defended place.

POST /v1/billing/webhooks/stripe

Where Stripe says what happened to the money: subscription changes, payment failures, and recoveries.

Signed with Stripe-Signature over the raw body, verified against the endpoint's signing secret, with a 5 minute timestamp tolerance. The body limit is 1 MB.

Answers 200 with an empty body.

Status Code Means
404 not_found The webhook is off, or no signing secret is configured
400 ingestion_rejected The signature did not verify
413 payload_too_large Over 1 MB

200 rather than 202: Stripe reads any 2xx as delivered and stops retrying, and 200 is what its dashboard and tooling expect. The distinction 202 would draw is real but is not one Stripe can use.

Why billing data lags a few seconds

This endpoint records and enqueues. It decides nothing. Everything that follows an event, re-reading the subscription from Stripe, moving the mirror, entering a grace period, happens in the Worker on the other side of a queue.

That is why a change somebody makes in the Stripe Customer Portal does not appear instantly in GET /v1/billing. If you need it now, call POST /v1/billing/sync. See Billing.

Duplicate deliveries

Stripe delivers at least once and retries for days. The ledger row for an event is claimed the moment the event is accepted, so a second delivery of an event Syllevo already holds is acknowledged with 200 and never enqueued twice. Out-of-order delivery converges rather than flapping, because the queue message carries only the event id and its type and the Worker re-fetches the subject from Stripe.

That last part is also a privacy property: no card data, no amount, and no customer detail ever reaches the message broker.