Partner API Reference

The full partner API surface.

Manifest v1.0

Everything a partner engineering team needs in one stable URL: intake POST auth, body shape, the 201 jobId response, polling resume, the outbound webhook contract, and the X-Halfpixel-Signature verifier. For full onboarding context — including becoming a partner — see /partners →

Authentication

Bearer-only. No cookies, no sessions.

Every authenticated request carries your partner key on the Authorization header. Mint and rotate keys freely on /settings/agency — revoking a key takes effect at the next request.

canonical header

Authorization: Bearer hp_partner_<your-key>

alias header (accepted; canonical preferred)

x-partner-key: hp_partner_<your-key>

  • Keys are minted at /settings/agency and are scoped to the account that created them.
  • 401 is returned for a missing header, an unrecognised key, or an admin-minted key with no holder — all cases are uniform so existence is never leaked.
  • No cookie-based auth, no session tokens, no second side-channel. The same Bearer key you POST with is what you use to poll.
Submit a brief

POST /api/intake

Accepts a PartnerBriefRequest body — the canonical IntakeRequest brief plus the partner-trace envelope. Returns 201 with a jobId and a statusUrl immediately.

header

Authorization: Bearer hp_partner_<your-key>

Mint yours at /settings/agency. Bearer-only auth — no cookies, no sessions.

curl — POST /api/intakeapplication/json
curl -X POST https://halfpixel.app/api/intake \
  -H 'Authorization: Bearer hp_partner_<your-key>' \
  -H 'content-type: application/json' \
  -d @brief.json
request body — PartnerBriefRequestapplication/json
{
  "brandName": "Acme Studio",
  "logoUrl": "https://acme.com/logo.svg",
  "palette": ["#1a2b3c", "#dfe6ef", "#f25c54"],
  "voice": "Bold sans-serif typographic-leaning visuals. Confident, modern, warm.",
  "targetChannels": ["instagram_square", "instagram_story"],

  // partner-trace envelope
  "campaignId": "summer-2026-launch",
  "ownerOrg": "Acme Studio",
  "submittedBy": "ops@acme.com",
  "format": "instagram_square",
  "trainingOptOut": true,

  // optional — omit if you prefer polling
  "webhookUrl": "https://acme.com/halfpixel/callback"
}
response — 201 Createdapplication/json
{
  "jobId": "cly_ab12cd34ef56gh78",
  "statusUrl": "/api/intake/cly_ab12cd34ef56gh78"
}

Error shapes

Bad request

400
{
  "errors": {
    "brandName": "brandName is required",
    "format": "format must be one of the manifest.formats keys"
  }
}

Unauthorized

401
{
  "error": "Unauthorized"
}

Unprocessable

422
{
  "error": "API key has no associated user"
}

Rate / quota

429
{
  "error": "Rate limit exceeded",
  "retryAfter": 60
}
Polling resume

GET /api/intake/[jobId]

Use the same Authorization: Bearer key you submitted with. The route returns the canonical job status, the brief you originally POSTed, and the full versioned artifact list — so you can drive your UI without waiting for the webhook.

header

Authorization: Bearer hp_partner_<your-key>

Mint yours at /settings/agency. Bearer-only auth — no cookies, no sessions.

curl — GET /api/intake/[jobId]application/json
curl https://halfpixel.app/api/intake/cly_ab12cd34ef56gh78 \
  -H 'Authorization: Bearer hp_partner_<your-key>'
response — status-response shapeapplication/json
{
  "id": "cly_ab12cd34ef56gh78",
  "status": "completed",
  "assetId": "cly_root9z8x7y6w5v",
  "shareUrl": "https://halfpixel.app/api/share/cly_root9z8x7y6w5v",
  "formats": ["png", "svg"],
  "createdAt": "2026-08-22T12:34:56.789Z",
  "updatedAt": "2026-08-22T12:34:57.012Z",
  "brief": {
    "brandName": "Acme Studio",
    "logoUrl": "https://acme.com/logo.svg",
    "palette": ["#1a2b3c", "#dfe6ef", "#f25c54"],
    "voice": "Bold sans-serif typographic-leaning visuals.",
    "targetChannels": ["instagram_square", "instagram_story"]
  },
  "artifacts": [
    {
      "versionId": "cly_root9z8x7y6w5v",
      "createdAt": "2026-08-22T12:34:57.012Z",
      "status": "shipped",
      "previewUrl": "https://halfpixel.app/api/share/cly_root9z8x7y6w5v"
    },
    {
      "versionId": "cly_itera1b2c3d4e5f",
      "createdAt": "2026-08-22T12:51:09.443Z",
      "status": "iterating",
      "previewUrl": "https://halfpixel.app/api/share/cly_itera1b2c3d4e5f"
    }
  ]
}
  • Status enum: queued · completed · failed. The internal DB marker done is renamed completed at the wire boundary.
  • Artifacts: chain-walked list — root asset plus every iterate descendant, ordered createdAt asc. Each entry's status is shipped or iterating.
  • Auth masking: a missing header, an unrecognised key, an unknown jobId, or a jobId that belongs to another partner all return 401 or 404 — existence is never revealed.
Outbound webhook

One callback per intake — no retries.

When you include webhookUrl in the POST body, the route fires exactly one outbound POST after the generation pass completes. The body is the IntakeCallbackBody schema.

outbound POST body — IntakeCallbackBodyapplication/json
{
  "jobId": "cly_ab12cd34ef56gh78",
  "assetId": "cly_root9z8x7y6w5v",
  "status": "shipped",
  "formats": ["png", "svg"],
  "shareUrl": "https://halfpixel.app/api/share/cly_root9z8x7y6w5v"
}
EventWire statusTrigger
job.completedshippedPOST /api/assets/[id]/ship marks a partner-sourced chain shipped.
job.iteratingiteratingPOST /api/generate/iterate applies iteration past the first pass to a partner-sourced root.
  • One POST per intake; no retries, no backoff. A failure (network, non-2xx, 5 s timeout) is recorded on the PartnerIntake row but does NOT trigger a follow-up call.
  • 5-second hard timeout via AbortController. A cutoff is captured as webhookResponseExcerpt on the row.
  • Callback fires only on a successful generation — status: 'done' internally. A failed generation does not call the webhook; poll to see state: 'failed'.
  • The response body is read as text and truncated to 500 chars. Return any shape your system expects; keep it short.
Security

Verifying webhook signatures.

Every outbound POST carries an X-Halfpixel-Signature header in the Stripe-style envelope t=<unix_seconds>,v1=<hex>. The signed input is <t>.<raw_json_body> and must be verified against the INTAKE_CALLBACK_SIGNING_SECRET shared with you at onboarding.

  • Algorithm: HMAC-SHA256, hex-encoded lowercase digest.
  • Signed input: <t>.<raw_body> — pass the raw request body verbatim. Re-stringifying breaks the signature.
  • Comparison: timingSafeEqual after a length check on the hex digests — length-first avoids throwing on unequal digests.
  • Replay window: reject timestamps more than 5 minutes off Date.now() to blunt replay attacks.
verify webhook signature (node)application/json
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.INTAKE_CALLBACK_SIGNING_SECRET; // operator-supplied; >=32 chars

export function verifyIntakeWebhook(headerValue, rawBody) {
  if (!headerValue || !rawBody || !SECRET) return false;

  const parts = Object.fromEntries(
    headerValue.split(',').map((kv) => {
      const i = kv.indexOf('=');
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Reject timestamps more than 5 minutes off to blunt replay attacks.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(skew) || skew > 300) return false;

  const expected = createHmac('sha256', SECRET)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(v1, 'hex');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
express wire-up (node)application/json
import express from 'express';
import { verifyIntakeWebhook } from './verify-intake-webhook';

const app = express();

app.post('/halfpixel/callback', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Halfpixel-Signature') ?? '';
  if (!verifyIntakeWebhook(header, req.body.toString('utf8'))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  // ...parse req.body as JSON and ack
  res.status(200).json({ ok: true });
});

Practical posture

Keep INTAKE_CALLBACK_SIGNING_SECRET out of the browser, scope the webhook URL to a private endpoint, and reject any callback whose verifier returns false — never fall back to a no-sig path.

Try it from your browser

Validate your partner key without leaving the docs.

Paste your hp_partner_… key, edit the prefilled brief if you want, and hit Send test intake. The widget POSTs to a same-origin proxy (/api/docs-proxy/intake → forwards to /api/intake) so the key rides only on the Authorization header — never in the body, never in localStorage, never logged. The upstream response (201 with jobId, 401, or 429 with quota) is rendered verbatim.

Same widget is also available on /partners#try-from-browser.

Mint yours on /settings/agency. The key stays in this widget only — never persisted, never logged.

Forwarded to /api/intake with your key on Authorization: Bearer …. 201 = accepted; 401 / 429 surface upstream verbatim.

Per-brief rate limits and the monthly quota on /api/intake apply here too — a key validation that hits the cap surfaces the upstream 429 (with retry-after + x-ratelimit-tier headers) in the response panel.

Manifest live reference

What /api/intake advertises.

The table below is a live read of GET /api/intake. Cross-check the version field if you cache the manifest on your side.

Next steps

See /partners for full onboarding context and the become-a-partner form, mint a key at /settings/agency, or review /pricing for tier comparison.