Skip to content

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.

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:

  1. Parse t and v1 out of the header.
  2. Reject if Math.abs(now - t) > 300 — five minutes, the replay window.
  3. Recompute the HMAC over `${t}.${rawBody}`.
  4. Compare to v1 with 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.

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);
});
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}`));
}

Create a source. The secret is shown once and stored encrypted — Typillar cannot show it to you again.

Terminal window
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.

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.

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.

Terminal window
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.


Subscribe an endpoint to the events you care about, or ["*"] for all of them:

Terminal window
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_…" } }

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 idstable 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.

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.

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.

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.


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 idea
const 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.