Skip to content

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.

EndpointPOST https://app.paperbeam.ai/api/ingest/webhook/<workspace-id>
Content typeapplication/json
Signature headerX-Paperbeam-Signature: sha256=<hex>
Max body1 MB
Max events per request100

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.

curl
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"]}
send-event.ts (Node 18+)
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.

FieldTypeRequiredDescription
idstring ≤ 200RecommendedYour idempotency key. Re-sending the same id updates the event instead of duplicating it.
typestring ≤ 100Yese.g. deal.won, release.shipped, metric.recorded, hire.started. Determines the kind (below).
kindenumNoOverrides the kind inferred from type: call, message, deal, expansion, churn, ticket, issue, release, metric, hr_event, tip, doc, event.
occurred_atISO 8601 with offsetYesWhen it happened, e.g. 2026-10-05T14:03:00Z. Decides which edition’s window it falls in.
titlestring 1–300YesA plain description of what happened.
bodystring ≤ 20,000NoDetails. Customer words here can be quoted verbatim.
urlURLNoLink to the record. Shown as the story’s source link.
actorsarray ≤ 50NoPeople involved: { name, role, title?, company?, email? }. role is customer, prospect, employee, partner, system or unknown.
amountnumber ≥ 0NoA money amount (annual for deals).
currency3-letter codeNoDefaults to USD.
customerstring ≤ 200NoCustomer or account name.
tagsstring[] ≤ 20NoFree-form tags. Exclusion rules can match them.
dataobjectNoExtra 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 withKind
deal, opportunity, contract, signeddeal (the part after the dot, e.g. won, lost, becomes the deal change)
expansion, upsell, upgradeexpansion
churn, cancel, downgradechurn
ticket, supportticket
issue, bug, incidentissue
release, deploy, ship, changelogrelease
metric, kpimetric
hire, hr, employee, anniversary, promotionhr_event
call, meetingcall
message, postmessage
doc, document, memodoc
anything elseevent

Responses

StatusBodyMeaning
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 from type, occurred_at and title, 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.