Bad request
{
"errors": {
"brandName": "brandName is required",
"format": "format must be one of the manifest.formats keys (see GET /api/intake)"
}
}Halfpixel's partner API is the same wire that powers /pricing: mint a partner key on /settings/agency, then issue a POST against /api/intake with the manifest payload below. Every endpoint documented here is the actual public surface — no hidden flags, no private auth bypass.
Every outbound POST from /api/intake 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 secret INTAKE_CALLBACK_SIGNING_SECRET shared with you at onboarding. The Node.js verifier below mirrors src/lib/business/webhook-sign.ts byte-for-byte.
<t>.<raw_body> — the raw, unrewritten JSON bytes the server hashed. Re-stringifying the body on your side will break the signature; pass the request body through verbatim.INTAKE_CALLBACK_SIGNING_SECRET — a 32+ char string minted by the operator and shared with you at onboarding. Same value on both sides.timingSafeEqual after a length check on the hex digests (length-first avoids throwing on unequal digests).Date.now() to blunt replay attacks.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);
}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 });
});Events currently emitted
| Event | Wire status | Trigger | Body |
|---|---|---|---|
| job.completed | shipped | POST /api/assets/[id]/ship marks a partner-sourced chain shipped. | IntakeCallbackBody |
| job.iterating | iterating | POST /api/generate/iterate applies iteration past the first pass to a partner-sourced root. | IntakeCallbackBody |
Both events share the wire shape IntakeCallbackBody with jobId, assetId, status, formats, and shareUrl — only status flips between shipped and iterating.
On your authenticated account, open /settings/agency and generate a partner API key (rotate freely; revoking a key takes effect at the next request). Then POST a brief against the public endpoint below. The response is a 202 with intakeId, token, status, and assetIds.
curl -X POST https://halfpixel.app/api/intake \
-H 'Authorization: Bearer hp_partner_<your-key>' \
-H 'content-type: application/json' \
-d '{ "brandName": "Acme Studio", "logoUrl": "https://acme.com/logo.svg", ... }'The table below is a live read of GET /api/intake. Cross-check the version field if you cache the manifest on your side.
Body shape is PartnerBriefRequest: every field in the canonical IntakeRequest brief, plus the partner-trace envelope (campaignId, ownerOrg, submittedBy, the single format, and trainingOptOut) and an OPTIONAL webhookUrl.
header
Authorization: Bearer hp_partner_<your-key>
Mint yours at /settings/agency. Bearer-only auth — no cookies, no sessions.
{
"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"],
"campaignId": "summer-2026-launch",
"ownerOrg": "Acme Studio",
"submittedBy": "ops@acme.com",
"format": "instagram_square",
"trainingOptOut": true,
"webhookUrl": "https://acme.com/halfpixel/callback"
}The route drives the full lifecycle INLINE today, so the synchronous response is status: 'done' with the asset IDs the partner owns. tokenis the polling credential for the GET side (see below) — keep it server-side, it's the only way to resume a lost webhook.
{
"intakeId": "cly_ab12cd34ef56gh78",
"token": "1f2e3d4c5b6a7980...64-hex-chars",
"status": "done",
"assetIds": [
"cly_root9z8x7y6w5v",
"cly_var1abcdef2345"
]
}All errors are JSON. The 400 shape echoes the zod field path so a partner can map messages straight to form fields; 401 is uniform across token / header absence and key-not-found so it doesn't leak existence; 500 carries detail for the synchronous POST caller (the row also persists failureReason for the polling partner).
{
"errors": {
"brandName": "brandName is required",
"format": "format must be one of the manifest.formats keys (see GET /api/intake)"
}
}{
"error": "Unauthorized"
}{
"error": "Generation failed",
"detail": "Recraft returned an empty SVG payload"
}When you supply webhookUrl on the POST, the route fires exactly one outbound POST after the inline generation pass completes. The body is the IntakeCallbackBodyschema: the partner's endpoint can parse it with the same zod types our route builds.
{
"jobId": "cly_ab12cd34ef56gh78",
"assetId": "cly_root9z8x7y6w5v",
"status": "shipped",
"formats": ["png", "svg"],
"shareUrl": "https://halfpixel.app/api/share/cly_root9z8x7y6w5v"
}AbortController. A cutoff is captured as webhookResponseExcerpt on the row.status: 'done'. A failed generation does not call the webhook — the partner sees state: 'failed' on a follow-up read instead.The synchronous 202 and the outbound webhook are best-effort. If either is lost (or you opted out of webhookUrl), you can resume with GET /api/intake/[jobId] using the SAME Authorization: Bearer credential you submitted the brief with — no cookies, no sessions, no second side token. The route returns the canonical job status, the original brief you POSTed, and the full versioned artifact list so you can drive your own UI without waiting on the webhook.
curl -X GET https://halfpixel.app/api/intake/cly_ab12cd34ef56gh78 \
-H 'Authorization: Bearer hp_partner_<your-key>'{
"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"
}
]
}queued · completed · failed. The DB lifecycle marker (done) is renamed completed at the boundary so the wire matches the inbound POST contract.createdAt asc. Each entry's status matches the closed enum on the outbound webhook (shipped when shippedAt IS NOT NULL, iterating otherwise).null defensively on a malformed row (rows written before strict validation shipped) so the GET never 500s on a stale partner intake.Authorization, an unrecognized key, an admin-minted key with no holder, an unknown jobId, or a jobId that belongs to another partner all return 401 or 404 — the route never reveals whether a specific job exists.Every POST to your webhookUrl arrives with an X-Halfpixel-Signature header of the form t=<unix_seconds>,v1=<lowercase_hex>, HMAC-SHA256 over <t>.<raw_json_body> using INTAKE_CALLBACK_SIGNING_SECRET. See the Verifying webhook signatures section above for the scheme rules, the Node.js verifier, the Express wire-up, and the events currently emitted.
Practical posture
Treat the signature as a real credential: 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 without jumping to a no-sig fallback.
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 below so you can read the same wire shape your integration will hit.
Brief wire shape: the default below matches IntakeRequest — the strict schema /api/intake accepts today. brandName, logoUrl, palette (1–8 hex colours), voice, and targetChannels. Edit freely; invalid JSON is surfaced inline.
Donation-tier worry: 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 so you can correlate without leaving the doc.
We whitelist a small number of Partner-tier agencies per quarter. Five fields, two minutes — if we're a fit you'll hear back inside one business day with a keys/onboarding window. Otherwise a short, human no.
Read /pricing for tier breakdown, or talk to us about becoming a Partner-tier agency — we'll come back inside one business day.