API
API reference
Full request/response schema for /api/queue and /api/feedback, plus the signed webhook payload format.
Authentication
All API calls authenticate with an API key in thex-checkpoint-key request header. Your first key can come from the dashboard under Connect → API keys — or be provisioned programmatically via the endpoint below. Keys are hashed server-side and shown only once at creation./api/keysProvision a cpk_ API key programmatically — no dashboard visit required. Unlike the other endpoints, this one authenticates with your Asenta account session (a Supabase access token), not an API key, since it is what mints those keys. Your first key can also come from the dashboard under Connect → API keys; this endpoint is the headless equivalent for API-first partners.
Authenticate with a Bearer token
Sign in with your Asenta (Supabase) email + password to obtain an access token, then send it asAuthorization: Bearer <token>. The key is always created for your own organization, resolved from the verified session — you cannot target another org from the request. The caller must hold the manage_keys role (owner or admin), mirroring the dashboard gate. A logged-in browser cookie session is also accepted.Request
# 1) Get a Supabase access token by signing in with your Asenta credentials.
ACCESS_TOKEN=$(curl -s -X POST \
"$SUPABASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "you@yourco.com", "password": "••••••••" }' \
| jq -r .access_token)
# 2) Mint a cpk_ API key. The org is taken from your session — never the body.
curl -X POST https://yourapp.com/api/keys \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "pipeline-prod" }'Request body fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | A human-readable label for the key, shown in the dashboard. Defaults to "API key" when omitted. |
Response
// HTTP 201 — the raw key is shown ONCE and never persisted or logged
{
"id": "0f3c...",
"key": "cpk_5f2a9c...e1",
"prefix": "cpk_5f2a9c1",
"name": "pipeline-prod",
"created_at": "2026-07-04T10:23:45.000Z"
}The raw key is shown once
Thekey field is the only time you will ever see the raw cpk_ value — it is hashed at rest and never logged. Store it immediately; if you lose it, mint a new one and revoke the old.| Status | Meaning |
|---|---|
| 201 | Key created. Body: { id, key, prefix, name, created_at }. The raw key is in `key`. |
| 401 | Missing, invalid, or expired access token (no valid session). |
| 403 | The authenticated user lacks the manage_keys role in their org. |
| 429 | Rate limited. Retry after the Retry-After header (per-IP and per-user throttles). |
| 500 | Internal error (database error). |
/api/queueSubmit an AI-generated output for review. The endpoint is unauthenticated by session — it uses your API key. Returns immediately with the item's ID and status; the human decision arrives later via webhook.
Request
curl -X POST https://yourapp.com/api/queue \
-H "x-checkpoint-key: cpk_live_<YOUR_KEY>" \
-H "x-checkpoint-idempotency-key: pipeline-run-20260628-item-42" \
-H "Content-Type: application/json" \
-d '{
"content": "<p>Hi Sarah, wanted to connect about Q3 hiring...</p>",
"confidence": 0.87,
"segment": "outbound-email",
"metadata": {
"prospect": "Sarah Chen",
"company": "Acme Corp",
"region": "US"
},
"on_approve_webhook": "https://your-pipeline.com/webhooks/send",
"on_reject_webhook": "https://your-pipeline.com/webhooks/discard",
"on_regenerate_webhook": "https://your-pipeline.com/webhooks/regen"
}'Request body fields
| Field | Type | Required | Description |
|---|---|---|---|
| content | string | required | The AI-generated output to queue for review. HTML or plain text. This is the content-of-record: if a reviewer edits before approving, the edited version replaces it and the original is preserved in original_content. |
| confidence | number | optional | A float 0–1 representing the pipeline's confidence in the output. Used by the oversight gate: if below the key's confidence_threshold, the item is always sent to review. Confidence is demote-only — a high value never bypasses always_review_rules. |
| segment | string | optional | A tag identifying the content category or audience (e.g. 'outbound-email', 'EU', 'support'). Used by always_review_rules, auto_bypass_rules, and the feedback API's segment filter. Prefer this top-level field; for backward compatibility metadata.segment and then metadata.country are accepted as fallbacks (precedence: segment ?? metadata.segment ?? metadata.country). |
| metadata | object | optional | Arbitrary key-value pairs attached to the item. Available in rule conditions via metadata.* and returned in webhook payloads. Not evaluated by the gate unless referenced in a rule. |
| template_id | string | optional | ID of an Asenta HTML template to use as the review wrapper. If omitted, content is displayed as-is. |
| review_template_key | string | optional | Key of an org-defined review template (the reviewer's display layout). The matching config is validated and frozen onto the item at intake, so later edits to the template don't change in-flight items. An unknown or invalid key falls back to the default layout — it is never an error. |
| on_approve_webhook | string | optional | URL to POST to when the item is approved. If omitted, the org-level default_approve_webhook is used. |
| on_reject_webhook | string | optional | URL to POST to when the item is rejected. Optional. |
| on_regenerate_webhook | string | optional | URL to POST to when a reviewer requests regeneration. Your pipeline should generate a new version and re-submit. |
| images | ImageInput[] | optional | Array of base64-encoded images to upload to the private asset store. Each entry: { filename: string, contentBase64: string, contentType?: string }. |
Idempotency
Passx-checkpoint-idempotency-key as a request header to make intake safe to retry. If the same key is received twice, the second call returns the original item (HTTP 200) without re-queueing or re-firing. Keys are scoped per org.Responses
201 — queued for review
// HTTP 201 — item queued for human review
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"status": "pending",
"created_at": "2026-06-28T10:23:45.000Z"
}201 — auto-fired by gate
// HTTP 201 — oversight gate cleared the item; webhook already fired
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"status": "approved",
"created_at": "2026-06-28T10:23:45.000Z"
}200 — idempotent read-back
// HTTP 200 — duplicate request; original item returned without re-firing
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"status": "pending",
"created_at": "2026-06-28T10:23:45.000Z"
}429 — monthly quota exceeded
// HTTP 429 — the org is at or over its monthly quota; item NOT queued
{
"error": "monthly quota exceeded",
"limit": 1000,
"used": 1000,
"plan": "free"
}| Status | Meaning |
|---|---|
| 400 | Missing or invalid content, or unparseable JSON body |
| 401 | Missing or invalid x-checkpoint-key header |
| 429 | Monthly quota exceeded. Body: { error, limit, used, plan }. Retry next month or upgrade the plan. |
| 500 | Internal error (image upload failure, database error) |
/api/queue/[id]/regeneratedThe regenerate return endpoint. When a reviewer clicks Regenerate, Asenta marks the item regenerating, mints a one-time regenerate_token, and fires your on_regenerate webhook with that token. Your pipeline produces a new version and posts it back to the same item here — it does not submit a fresh item to /api/queue. Posting a new item would leave the original stuck in regenerating.
Return, don't re-submit
A common mistake is re-POSTing to/api/queue on on_regenerate. That creates a brand-new item and abandons the original. Always POST the new version to /api/queue/<id>/regenerated with the token from the webhook.Request
# Called by your pipeline after it handles an on_regenerate webhook.
# Post the NEW version back to the SAME item, echoing the regenerate_token
# you received in the on_regenerate payload.
curl -X POST https://yourapp.com/api/queue/qi_01HX2AbCdEfGhIjKlMnOpQ/regenerated \
-H "x-checkpoint-key: cpk_live_<YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{
"content": "<p>Hi Sarah, a cleaner second pass about Q3 hiring...</p>",
"regenerate_token": "b1e7...-the-token-from-the-webhook",
"confidence": 0.9,
"metadata": { "regenerated_by": "pipeline-v2" }
}'Request body fields
| Field | Type | Required | Description |
|---|---|---|---|
| content | string | required | The regenerated output. Becomes the item's new content-of-record; original_content is reset (the model output is the baseline again until a human edits it). |
| regenerate_token | string | required | The token echoed from the on_regenerate webhook payload. The update only applies when the item is still regenerating AND this token matches — otherwise the return is rejected as superseded/cancelled/expired (409). |
| confidence | number | optional | A float 0–1 for the new version. Feeds the auto-fire gate below; if omitted the item's existing confidence is left unchanged. |
| metadata | object | optional | Merged over the item's existing metadata. The original webhook routing (metadata.webhooks) is always preserved regardless of what you send. |
Auto-fire on return is demote-only
The returned content is re-scored by the same oversight gate. The gate can only demote a returned item to review — a returned version auto-fires only when the originating key hasaudit_mode or auto_fire_on_regenerate enabled, and only when that same originating key authenticates the return. If a different key returns the content, or anything in the gate config errors, the item fails closed to review (the content is still accepted — you are never 401'd for the mismatch). When it does auto-fire, the on_approve webhook fires exactly as on the intake auto path.Responses
200 — applied, back in review
// HTTP 200 — new version applied; back in the queue for review
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"status": "pending"
}200 — applied and auto-fired
// HTTP 200 — new version applied AND auto-fired (see the auto-fire gate below)
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"status": "approved"
}200 — already applied (safe retry)
// HTTP 200 — safe retry; this version was already applied, nothing re-fired
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"status": "already_applied"
}| Status | Meaning |
|---|---|
| 200 | Applied (status pending or approved), or a safe retry (status already_applied when the same token was already consumed). |
| 400 | Missing/empty content, or unparseable JSON body |
| 401 | Missing or invalid x-checkpoint-key header |
| 404 | No item with this id in the authenticated key's org |
| 409 | Regeneration superseded, cancelled, or expired — the item is no longer regenerating or the token no longer matches, and it was not a safe retry |
| 500 | Internal error |
Idempotent by token
The return is a single-row compare-and-set on(status = regenerating, regenerate_token match). Retrying the same return after it succeeded returns 200 { status: "already_applied" } without re-firing anything. A late return after the reviewer cancelled (or the item expired) gets 409 instead of silently overwriting.Decision webhooks
Asenta POSTs a signed payload to your configured endpoint. Four events exist — three fire on a human decision, and on_pending fires at intake when an item is queued for review:
on_pendingAn item was queued for human review. Fires at intake, before any decision. Use it to open a review task in your own system. Org-default only (default_pending_webhook) — there is no per-item on_pending URL.on_approveThe item was approved (with or without edits). content reflects the final, reviewer-approved version.on_rejectThe item was rejected. No action should be taken.on_regenerateA reviewer requested a new generation. The payload includes a regenerate_token — produce a new version and POST it to /api/queue/<id>/regenerated, echoing that token (see above). Do NOT submit a fresh item to /api/queue.
Where each URL comes from
on_approve, on_reject, and on_regenerate resolve per item first (the on_*_webhook fields on intake), falling back to the org defaults default_approve_webhook, default_reject_webhook, and default_regenerate_webhook. on_pending has no per-item field — it uses default_pending_webhook only. Org defaults are set in Settings.Payload
// POST to your on_approve_webhook URL
// Headers:
// X-Checkpoint-Signature: sha256=a1b2c3d4... (HMAC-SHA256 of the raw body)
// X-Checkpoint-Event: on_approve
// Content-Type: application/json
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"content": "<p>Hi Sarah, wanted to connect about Q3 hiring...</p>",
"metadata": {
"prospect": "Sarah Chen",
"company": "Acme Corp",
"webhooks": {
"on_approve": "https://your-pipeline.com/webhooks/send",
"on_reject": "https://your-pipeline.com/webhooks/discard",
"on_regenerate": null
}
},
"event": "on_approve",
"idempotency_key": "qi_01HX2...:on_approve:"
}The on_regenerate payload additionally carries the regenerate_token you must echo back to the return endpoint:
// POST to your on_regenerate webhook URL
// Headers:
// X-Checkpoint-Signature: sha256=a1b2c3d4... (HMAC-SHA256 of the raw body)
// X-Checkpoint-Event: on_regenerate
// Content-Type: application/json
{
"id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
"content": "<p>Hi Sarah, wanted to connect about Q3 hiring...</p>",
"metadata": {
"prospect": "Sarah Chen",
"company": "Acme Corp"
},
"regenerate_token": "b1e7...-echo-this-back",
"event": "on_regenerate",
"idempotency_key": "qi_01HX2...:on_regenerate:"
}Verifying the signature
The X-Checkpoint-Signature header is sha256=<hex> where the hex is an HMAC-SHA256 of the raw request body bytes (not the parsed JSON) keyed with your workspace's signing secret. Always verify before acting.
Your workspace has its own signing secret
Each org has its own webhook signing secret — it is not a shared platform value. Reveal or rotate it in Settings → Webhooks → Signing secret (requires themanage_keys role), then store it in your webhook handler's environment (the WEBHOOK_SECRET in the snippets below is your copy of that value). Rotating is immediate: webhooks in flight signed with the old secret will fail verification until your endpoint is updated with the new one.Use the raw body
Parsing the body before verification (e.g. withexpress.json()) changes whitespace and key ordering, which will break the HMAC check. Use express.raw() in Node or read the raw bytes in any other framework.import crypto from "crypto";
import express from "express";
const app = express();
// IMPORTANT: use express.raw() — not express.json() — to get the raw body buffer.
// The signature is computed over the raw byte string; parsing first breaks verification.
app.post(
"/webhooks/checkpoint",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-checkpoint-signature"] as string; // "sha256=abc..."
const event = req.headers["x-checkpoint-event"] as string; // "on_approve"
// Compute expected signature
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(req.body) // raw Buffer
.digest("hex");
// Timing-safe comparison
const sigBuf = Buffer.from(sig ?? "");
const expBuf = Buffer.from(expected);
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return res.status(401).json({ error: "invalid signature" });
}
const payload = JSON.parse(req.body.toString());
// payload: { id, content, metadata, event, idempotency_key }
if (event === "on_approve") {
// carry out the action — e.g., send the email
await sendEmail(payload.content, payload.metadata);
}
res.status(200).json({ received: true });
}
);Retry behaviour
Asenta retries each webhook up to 3 times on network error, 5xx, or 429 responses. Successful delivery (2xx) stops retries. Use theidempotency_key field in the payload to safely deduplicate retries on your side./api/feedbackReturns the org's promoted style rules and recent edit-pair examples. Call this from your generation pipeline and inject the results into your system prompt to reduce the edit rate over time.
Request
# All rules and examples for this org
curl https://yourapp.com/api/feedback \
-H "x-checkpoint-key: cpk_live_<YOUR_KEY>"
# Filtered to a specific segment
curl "https://yourapp.com/api/feedback?segment=outbound-email" \
-H "x-checkpoint-key: cpk_live_<YOUR_KEY>"| Parameter | Where | Description |
|---|---|---|
| x-checkpoint-key | Header | Required. Your API key. |
| segment | Query param | Optional. Filter both style_rules and examples to this segment value. |
Response
{
"style_rules": [
{
"id": "sr_01HX...",
"segment": "outbound-email",
"rule_text": "Keep subject lines under 7 words.",
"source": "promoted"
},
{
"id": "sr_01HY...",
"segment": null,
"rule_text": "Do not use exclamation marks in B2B copy.",
"source": "promoted"
}
],
"examples": [
{
"segment": "outbound-email",
"original": "<p>Hi Sarah — I wanted to reach out about your team's hiring plans.</p>",
"edited": "<p>Hi Sarah, quick question about your Q3 hiring plans.</p>"
}
]
}| Field | Description |
|---|---|
| style_rules[].id | Unique rule ID. |
| style_rules[].segment | The segment this rule applies to, or null for org-wide rules. |
| style_rules[].rule_text | Plain-English rule text to inject into your system prompt. |
| style_rules[].source | How the rule was created. Currently always 'promoted' (promoted from an edit pair). |
| examples[].segment | Segment tag on the source item, or null. |
| examples[].original | The AI's original output (before editing). |
| examples[].edited | The human-approved final version. |