Webhook ingest API
Push events from any system to your paper's signed ingest endpoint: signing, event schema, limits and responses.
Push events to your paper from any system: a deploy pipeline, a billing system, a spreadsheet script, Zapier. Events are verified with an HMAC signature, then treated like items from any other source. Available on Growth, Business and Enterprise.
Get your endpoint and secret
Go to Sources›Webhook / API and create a webhook source. You’ll see the endpoint and a signing secret (whsec_…). The secret is shown once; admins can reveal it again from the source’s page, and every reveal is recorded in the audit log. You can create several webhook sources, for example one per sending system.
| Endpoint | POST https://app.paperbeam.ai/api/ingest/webhook/<workspace-id> |
| Content type | application/json |
| Signature header | X-Paperbeam-Signature: sha256=<hex> |
| Max body | 1 MB |
| Max events per request | 100 |
Signing requests
Compute an HMAC-SHA256 of the raw request body (the exact bytes you send) using your secret as the key, hex-encode it, and send it as sha256=<hex>. Paperbeam compares signatures in constant time.
SECRET='whsec_...'
URL='https://app.paperbeam.ai/api/ingest/webhook/<workspace-id>'
BODY='{"id":"ctr_1042","type":"deal.won","occurred_at":"2026-10-05T14:03:00Z","title":"Globex signs a 3-year enterprise agreement","customer":"Globex Freight","amount":186000,"currency":"USD","actors":[{"name":"Dana Kim","role":"employee","title":"Account Executive"}]}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')"
curl -sS -X POST "$URL" \
-H 'content-type: application/json' \
-H "x-paperbeam-signature: $SIG" \
-d "$BODY"
# → 202 {"accepted":1,"ids":["webhook:ctr_1042"]}import { createHmac } from "node:crypto";
export async function sendToPaperbeam(events: object[]) {
const body = JSON.stringify({ events });
const sig = createHmac("sha256", process.env.PAPERBEAM_WEBHOOK_SECRET!).update(body, "utf8").digest("hex");
const res = await fetch(process.env.PAPERBEAM_WEBHOOK_URL!, {
method: "POST",
headers: { "content-type": "application/json", "x-paperbeam-signature": `sha256=${sig}` },
body, // send exactly the string you signed
});
if (!res.ok) throw new Error(`Paperbeam ingest failed: ${res.status} ${await res.text()}`);
return res.json() as Promise<{ accepted: number; ids: string[] }>;
}Request body
Send one event object, or a batch: { "events": [ … ] } with 1 to 100 events. Unknown fields are rejected.
| Field | Type | Required | Description |
|---|---|---|---|
| id | string ≤ 200 | Recommended | Your idempotency key. Re-sending the same id updates the event instead of duplicating it. |
| type | string ≤ 100 | Yes | e.g. deal.won, release.shipped, metric.recorded, hire.started. Determines the kind (below). |
| kind | enum | No | Overrides the kind inferred from type: call, message, deal, expansion, churn, ticket, issue, release, metric, hr_event, tip, doc, event. |
| occurred_at | ISO 8601 with offset | Yes | When it happened, e.g. 2026-10-05T14:03:00Z. Decides which edition’s window it falls in. |
| title | string 1–300 | Yes | A plain description of what happened. |
| body | string ≤ 20,000 | No | Details. Customer words here can be quoted verbatim. |
| url | URL | No | Link to the record. Shown as the story’s source link. |
| actors | array ≤ 50 | No | People involved: { name, role, title?, company?, email? }. role is customer, prospect, employee, partner, system or unknown. |
| amount | number ≥ 0 | No | A money amount (annual for deals). |
| currency | 3-letter code | No | Defaults to USD. |
| customer | string ≤ 200 | No | Customer or account name. |
| tags | string[] ≤ 20 | No | Free-form tags. Exclusion rules can match them. |
| data | object | No | Extra fields. Values must be strings, numbers, booleans or null. |
How type maps to kind
If type is itself a kind (e.g. release), that’s the kind. Otherwise its first word decides:
| type starts with | Kind |
|---|---|
| deal, opportunity, contract, signed | deal (the part after the dot, e.g. won, lost, becomes the deal change) |
| expansion, upsell, upgrade | expansion |
| churn, cancel, downgrade | churn |
| ticket, support | ticket |
| issue, bug, incident | issue |
| release, deploy, ship, changelog | release |
| metric, kpi | metric |
| hire, hr, employee, anniversary, promotion | hr_event |
| call, meeting | call |
| message, post | message |
| doc, document, memo | doc |
| anything else | event |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | {"accepted": n, "ids": [...]} | Stored. Ids are prefixed webhook:. |
| 400 | {"error": "Body must be JSON."} | Malformed JSON, or not 1 to 100 events. |
| 401 | {"error": "Invalid signature."} | Missing, malformed or wrong signature, or unknown workspace. |
| 402 | {"error": "…"} | Webhook ingest isn’t on your plan. |
| 413 | {"error": "Payload too large."} | Body over 1 MB. |
| 422 | {"error": "Invalid event.", "issues": [{"path", "message"}]} | Validation failed. Up to 5 issues are listed. |
Retries and idempotency
- Always set
id. Without it, Paperbeam derives one fromtype,occurred_atandtitle, so two different events with the same three values would collapse into one. - Retry on network errors and 5xx with exponential backoff. Don’t retry 4xx without fixing the request.
- Signatures cover the body only, with no timestamp. Treat the secret like a password, and send over HTTPS only (the endpoint is HTTPS-only).
What makes the paper
Pushed events are read by the next edition whose window contains occurred_at. Like everything else, they go through extraction, the story budget and verification. A $186,000 signed contract with a customer name and a quote is a likely lede. A routine “build passed” event is not, so don’t send noise.