Webhooks
Typillar speaks HTTP in both directions, and both directions use the same signing scheme and the same secret format. Learn it once.
- Ingest — you POST an idea into a project. Typillar verifies your signature.
- Events — Typillar POSTs a lifecycle event to your URL. You verify its signature.
There is exactly one payload shape for each direction. Typillar does not ship a GitHub format, a Linear format, and a Slack format — you translate whatever you have into this contract, in a function you control.
The signature scheme
Section titled “The signature scheme”Every signed request, in either direction, carries one header:
Typillar-Signature: t=1751000000,v1=1f8ac10f23c5b5bc11678d86f8b6f5c9a1b0d0e2...t is a Unix timestamp in seconds. v1 is a lowercase hex
HMAC-SHA256, keyed by the endpoint’s secret, over this exact string:
<t> + "." + <raw request body>The . is a literal period. The body is the raw bytes as sent — not
re-serialized JSON. JSON.parse followed by JSON.stringify will reorder or
respace the payload and the signature will not match.
To verify:
- Parse
tandv1out of the header. - Reject if
Math.abs(now - t) > 300— five minutes, the replay window. - Recompute the HMAC over
`${t}.${rawBody}`. - Compare to
v1with a constant-time comparison.
Comparing with === leaks the correct signature one byte at a time to an
attacker who can time your responses. Use crypto.timingSafeEqual, or WebCrypto’s
crypto.subtle.verify, which is constant-time by construction.
Verifying in Node
Section titled “Verifying in Node”import crypto from "node:crypto";
export function verify(secret, rawBody, header, nowSeconds = Date.now() / 1000) { const parts = Object.fromEntries( header.split(",").map((kv) => { const i = kv.indexOf("="); return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()]; }), );
const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(nowSeconds - t) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); const got = Buffer.from(parts.v1 ?? "", "hex"); return want.length === got.length && crypto.timingSafeEqual(want, got);}In Express, reach for express.raw({ type: "application/json" }) on the webhook
route. The default express.json() body parser consumes the stream and leaves
you with an object, and you cannot reconstruct the original bytes from it.
app.post("/typillar", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); if (!verify(process.env.TYPILLAR_SECRET, raw, req.get("typillar-signature") ?? "")) { return res.sendStatus(401); } const event = JSON.parse(raw); // … res.sendStatus(200);});Verifying in a Worker
Section titled “Verifying in a Worker”const enc = new TextEncoder();
export async function verify(secret, rawBody, header, nowSeconds) { const parts = Object.fromEntries( header.split(",").map((kv) => { const i = kv.indexOf("="); return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()]; }), );
const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(nowSeconds - t) > 300) return false;
const key = await crypto.subtle.importKey( "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ); const sig = Uint8Array.from((parts.v1 ?? "").match(/../g) ?? [], (b) => parseInt(b, 16)); return crypto.subtle.verify("HMAC", key, sig, enc.encode(`${t}.${rawBody}`));}Ingest: posting ideas in
Section titled “Ingest: posting ideas in”Create a source. The secret is shown once and stored encrypted — Typillar cannot show it to you again.
curl -X POST https://<your-api-host>/api/v1/ingest \ -H 'content-type: application/json' \ --cookie "$SESSION" \ -d '{ "projectId": "prj_…" }'# → { "ok": true, "data": { "id": "ing_…", "secret": "whsec_…",# "webhookUrl": "https://hooks.typillar.com/i/ing_…" } }Then POST a signed JSON body to webhookUrl:
{ "title": "Checkout 500s on Safari", // becomes the ticket title "text": "Repro: add to cart, …", // becomes the ticket body "id": "ISSUE-4821", // your id for the thing this came from "url": "https://linear.app/…" // linked from the ticket}Every field is optional, but a body with neither title nor text is
accepted and ignored. If you send only one of them, it fills both — except that
a text-only body gets a title truncated from it at 60 characters. Send a
title when you care what the ticket is called.
Idempotency
Section titled “Idempotency”id is your identifier for whatever the idea came from — an issue key, a row id,
a message ts. Post the same id to the same source again and you get the idea
already captured, not a second one. A tracker that retries, a queue that
delivers twice, a cron that re-scans — none of them can duplicate a ticket.
The key is scoped per source, so two different sources may safely use 1.
Omit id and every POST creates a new idea. That is the correct choice for
genuinely new input (a form submission), and the wrong one for anything replayable.
Responses
Section titled “Responses”| Status | Meaning |
|---|---|
202 |
Accepted and queued. The ticket is created a moment later, not before this response returns. |
204 |
Valid signature, but the body had neither title nor text. Deliberately ignored. |
401 |
Missing, malformed, stale, or wrong Typillar-Signature. |
404 |
No such ingest source, or it has been removed. |
202 means queued, not ticketed. Do not poll for the ticket in the same
breath — subscribe to ticket.created if you need to know it landed.
A worked example
Section titled “A worked example”SECRET='whsec_…'BODY='{"title":"Checkout 500s on Safari","id":"ISSUE-4821"}'T=$(date +%s)SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "https://hooks.typillar.com/i/ing_…" \ -H 'content-type: application/json' \ -H "typillar-signature: t=$T,v1=$SIG" \ --data-raw "$BODY"--data-raw matters: -d strips newlines, and the signature covers the bytes.
An ingested idea becomes a ticket in the backlog, marked ingest rather than
human, with url linked from its body so the ticket always points home.
Events: receiving lifecycle events
Section titled “Events: receiving lifecycle events”Subscribe an endpoint to the events you care about, or ["*"] for all of them:
curl -X POST https://<your-api-host>/api/v1/webhooks \ -H 'content-type: application/json' \ --cookie "$SESSION" \ -d '{ "url": "https://example.com/hook", "events": ["project.deployed"] }'# → { "ok": true, "data": { "id": "whe_…", "secret": "whsec_…" } }The delivery
Section titled “The delivery”Each delivery is a POST with four headers that matter:
| Header | Value |
|---|---|
Typillar-Signature |
t=<unix>,v1=<hex> — as above |
Typillar-Event |
the event type, e.g. project.deployed |
Typillar-Delivery-Id |
the envelope’s id — stable across retries |
Content-Type |
application/json |
The body is an EventEnvelope:
{ "id": "evt_…", "type": "project.deployed", "projectId": "prj_…", "createdAt": 1751000000000, "data": { "url": "https://…" }}createdAt is milliseconds; the t in the signature is seconds. They
are not the same clock reading and are not interchangeable.
Events
Section titled “Events”These eleven names are the whole set. There is no separate event for a thing you
typed — a captured sentence is a ticket, so it arrives as ticket.created with
your original words kept on the ticket’s origin. Subscribing to a name that is
not on this list is a 400 invalid_request.
| Event | Fires when |
|---|---|
project.created |
a project is created |
ticket.created |
a ticket is opened — captured from a sentence, written by the Plan agent, or posted in through ingest |
ticket.updated |
a ticket’s fields change |
ticket.build.succeeded |
a build finishes and produces an app |
ticket.build.failed |
a build fails |
project.deployed |
a Deployment goes live |
project.rolled_back |
a Deployment is rolled back — a live release taken off its URL |
project.reverted |
the code is restored to an earlier version |
project.renamed |
a project is renamed, by the build agent or by a human |
project.archived |
a project is archived — taken offline, its data kept |
project.deleted |
a project is deleted, and its Cloudflare resources destroyed |
Two of those need reading carefully.
project.reverted is Restore; project.rolled_back is Roll back. They are
different acts and they fire on different things. project.reverted fires when
you restore the project’s code to an earlier version, and carries the sha you
restored to. project.rolled_back fires when you take a Deployment off its URL,
and carries the resourceId you rolled back plus production: true if the
release you removed was the live one.
project.archived does not mean torn down. Archiving takes the app offline
and frees its live-project slot; the database and files stay. Only
project.deleted means the resources are gone.
Retries, and why you must deduplicate
Section titled “Retries, and why you must deduplicate”Delivery runs through a durable queue. Any non-2xx response, or a connection failure, is retried — up to 5 retries after the first attempt. A brief outage on your side does not lose events.
Two consequences you have to design for:
Each retry is signed afresh. The t in Typillar-Signature is the time of
that attempt, so the whole header differs between attempts of the same event.
The signature is not an identity. Do not deduplicate on it.
Typillar-Delivery-Id is stable across retries. It is the envelope’s id.
Deduplicate on that — record it, and ignore an id you have already processed.
Delivery is at-least-once, and unordered. Two events for one ticket may
arrive out of order, or a retry of an older event may land after a newer one.
Treat createdAt as the ordering key if order matters to you.
Return a 2xx quickly and do your work afterwards. Typillar counts a slow or
failing response as a failed attempt and will send the event again.
There is no delivery log
Section titled “There is no delivery log”If your endpoint returns 500 for a week, Typillar retries each event five times
and then drops it. The console will not tell you this happened — there is no
delivery history UI today, and lastDeliveryAt on the endpoint only advances on
success. Watch that timestamp, or log deliveries on your own side.
Bringing in a tracker
Section titled “Bringing in a tracker”To pull from GitHub, Linear, or Slack — each of which controls its own payload shape and its own signing scheme — translate it into the ingest contract in a small function of your own:
// GitHub issue → Typillar ideaconst idea = { title: issue.title, text: issue.body ?? "", id: `gh-${issue.id}`, // stable → GitHub's redeliveries can't duplicate url: issue.html_url,};Typillar ships one inbound format rather than chasing each vendor’s, and one outbound format rather than shipping a Slack mode. Keeping the vendor’s quirks in your function — where you can see them and change them — is the trade.