Bad request
{
"errors": {
"brandName": "brandName is required",
"format": "format must be one of the manifest.formats keys"
}
}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 →
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>
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.Bearer key you POST with is what you use to poll.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 -X POST https://halfpixel.app/api/intake \
-H 'Authorization: Bearer hp_partner_<your-key>' \
-H 'content-type: application/json' \
-d @brief.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"
}{
"jobId": "cly_ab12cd34ef56gh78",
"statusUrl": "/api/intake/cly_ab12cd34ef56gh78"
}Error shapes
{
"errors": {
"brandName": "brandName is required",
"format": "format must be one of the manifest.formats keys"
}
}{
"error": "Unauthorized"
}{
"error": "API key has no associated user"
}{
"error": "Rate limit exceeded",
"retryAfter": 60
}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 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 internal DB marker done is renamed completed at the wire boundary.createdAt asc. Each entry's status is shipped or iterating.jobId, or a jobId that belongs to another partner all return 401 or 404 — existence is never revealed.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.
{
"jobId": "cly_ab12cd34ef56gh78",
"assetId": "cly_root9z8x7y6w5v",
"status": "shipped",
"formats": ["png", "svg"],
"shareUrl": "https://halfpixel.app/api/share/cly_root9z8x7y6w5v"
}| Event | Wire status | Trigger |
|---|---|---|
| job.completed | shipped | POST /api/assets/[id]/ship marks a partner-sourced chain shipped. |
| job.iterating | iterating | POST /api/generate/iterate applies iteration past the first pass to a partner-sourced root. |
AbortController. A cutoff is captured as webhookResponseExcerpt on the row.status: 'done' internally. A failed generation does not call the webhook; poll to see state: 'failed'.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.
<t>.<raw_body> — pass the raw request body verbatim. Re-stringifying breaks the signature.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 });
});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.
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.
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.
The table below is a live read of GET /api/intake. Cross-check the version field if you cache the manifest on your side.
See /partners for full onboarding context and the become-a-partner form, mint a key at /settings/agency, or review /pricing for tier comparison.