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 the x-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.
POST/api/keys

Provision 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 as Authorization: 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

bash
# 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

FieldTypeRequiredDescription
namestringoptionalA human-readable label for the key, shown in the dashboard. Defaults to "API key" when omitted.

Response

json
// 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

The key 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.
StatusMeaning
201Key created. Body: { id, key, prefix, name, created_at }. The raw key is in `key`.
401Missing, invalid, or expired access token (no valid session).
403The authenticated user lacks the manage_keys role in their org.
429Rate limited. Retry after the Retry-After header (per-IP and per-user throttles).
500Internal error (database error).
POST/api/queue

Submit 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

bash
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

FieldTypeRequiredDescription
contentstringrequiredThe 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.
confidencenumberoptionalA 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.
segmentstringoptionalA 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).
metadataobjectoptionalArbitrary 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_idstringoptionalID of an Asenta HTML template to use as the review wrapper. If omitted, content is displayed as-is.
review_template_keystringoptionalKey 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_webhookstringoptionalURL to POST to when the item is approved. If omitted, the org-level default_approve_webhook is used.
on_reject_webhookstringoptionalURL to POST to when the item is rejected. Optional.
on_regenerate_webhookstringoptionalURL to POST to when a reviewer requests regeneration. Your pipeline should generate a new version and re-submit.
imagesImageInput[]optionalArray of base64-encoded images to upload to the private asset store. Each entry: { filename: string, contentBase64: string, contentType?: string }.

Idempotency

Pass x-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

json
// 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

json
// 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

json
// 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

json
// HTTP 429 — the org is at or over its monthly quota; item NOT queued
{
  "error": "monthly quota exceeded",
  "limit": 1000,
  "used":  1000,
  "plan":  "free"
}
StatusMeaning
400Missing or invalid content, or unparseable JSON body
401Missing or invalid x-checkpoint-key header
429Monthly quota exceeded. Body: { error, limit, used, plan }. Retry next month or upgrade the plan.
500Internal error (image upload failure, database error)
POST/api/queue/[id]/regenerated

The 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

bash
# 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

FieldTypeRequiredDescription
contentstringrequiredThe 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_tokenstringrequiredThe 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).
confidencenumberoptionalA float 0–1 for the new version. Feeds the auto-fire gate below; if omitted the item's existing confidence is left unchanged.
metadataobjectoptionalMerged 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 has audit_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

json
// HTTP 200 — new version applied; back in the queue for review
{
  "id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
  "status": "pending"
}

200 — applied and auto-fired

json
// HTTP 200 — new version applied AND auto-fired (see the auto-fire gate below)
{
  "id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
  "status": "approved"
}

200 — already applied (safe retry)

json
// HTTP 200 — safe retry; this version was already applied, nothing re-fired
{
  "id": "qi_01HX2AbCdEfGhIjKlMnOpQ",
  "status": "already_applied"
}
StatusMeaning
200Applied (status pending or approved), or a safe retry (status already_applied when the same token was already consumed).
400Missing/empty content, or unparseable JSON body
401Missing or invalid x-checkpoint-key header
404No item with this id in the authenticated key's org
409Regeneration superseded, cancelled, or expired — the item is no longer regenerating or the token no longer matches, and it was not a safe retry
500Internal 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

json
// 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:

json
// 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 the manage_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. with express.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.
webhook-handler.ts
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 the idempotency_key field in the payload to safely deduplicate retries on your side.
GET/api/feedback

Returns 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

bash
# 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>"
ParameterWhereDescription
x-checkpoint-keyHeaderRequired. Your API key.
segmentQuery paramOptional. Filter both style_rules and examples to this segment value.

Response

json
{
  "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>"
    }
  ]
}
FieldDescription
style_rules[].idUnique rule ID.
style_rules[].segmentThe segment this rule applies to, or null for org-wide rules.
style_rules[].rule_textPlain-English rule text to inject into your system prompt.
style_rules[].sourceHow the rule was created. Currently always 'promoted' (promoted from an edit pair).
examples[].segmentSegment tag on the source item, or null.
examples[].originalThe AI's original output (before editing).
examples[].editedThe human-approved final version.