For partners

Build Halfpixel into your brand pipeline.

Manifest v1.0

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.

Talk to us →Inbound partner intake — we'll come back inside one business day.
Security

Verifying webhook signatures.

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.

  • Algorithm: HMAC-SHA256, hex-encoded lowercase digest.
  • Signed input: <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.
  • Secret: INTAKE_CALLBACK_SIGNING_SECRET — a 32+ char string minted by the operator and shared with you at onboarding. Same value on both sides.
  • 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 });
});

Events currently emitted

EventWire statusTriggerBody
job.completedshippedPOST /api/assets/[id]/ship marks a partner-sourced chain shipped.IntakeCallbackBody
job.iteratingiteratingPOST /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.

Quickstart

Mint a partner key, POST a brief.

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.

curlapplication/json
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", ... }'
Manifest 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.

Worked POST sample

Copy-paste a valid PartnerBriefRequest.

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.

request — POST /api/intakeapplication/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"],
  "campaignId": "summer-2026-launch",
  "ownerOrg": "Acme Studio",
  "submittedBy": "ops@acme.com",
  "format": "instagram_square",
  "trainingOptOut": true,
  "webhookUrl": "https://acme.com/halfpixel/callback"
}
Response (202 Accepted)

What the route sends back on success.

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.

response (202)application/json
{
  "intakeId": "cly_ab12cd34ef56gh78",
  "token": "1f2e3d4c5b6a7980...64-hex-chars",
  "status": "done",
  "assetIds": [
    "cly_root9z8x7y6w5v",
    "cly_var1abcdef2345"
  ]
}
Error shapes

What the route sends back on failure.

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).

Bad request

400
{
  "errors": {
    "brandName": "brandName is required",
    "format": "format must be one of the manifest.formats keys (see GET /api/intake)"
  }
}

Unauthorized

401
{
  "error": "Unauthorized"
}

Generation failed

500
{
  "error": "Generation failed",
  "detail": "Recraft returned an empty SVG payload"
}
Outbound webhook

The one callback the route fires after generation.

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.

outbound POST to your webhookUrlapplication/json
{
  "jobId": "cly_ab12cd34ef56gh78",
  "assetId": "cly_root9z8x7y6w5v",
  "status": "shipped",
  "formats": ["png", "svg"],
  "shareUrl": "https://halfpixel.app/api/share/cly_root9z8x7y6w5v"
}
  • One POST per intake; no retries, no backoff. A failure (network, non-2xx, 5s timeout) is recorded on the PartnerIntake row but does NOT cause a follow-up call.
  • 5-second hard timeout via AbortController. A cutoff is captured as webhookResponseExcerpt on the row.
  • The route fires the callback only on status: 'done'. A failed generation does not call the webhook — the partner sees state: 'failed' on a follow-up read instead.
  • The response is read as text and truncated to 500 chars; return whatever shape your system expects, but keep it short.
Polling resume

GET /api/intake/[jobId]

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.

polling resume — curlapplication/json
curl -X GET https://halfpixel.app/api/intake/cly_ab12cd34ef56gh78 \
  -H 'Authorization: Bearer hp_partner_<your-key>'
polling resume — GET /api/intake/cly_ab12cd34ef56gh78application/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 DB lifecycle marker (done) is renamed completed at the boundary so the wire matches the inbound POST contract.
  • Artifacts is the chain-walked list — the root asset plus every iterate descendant, ordered by createdAt asc. Each entry's status matches the closed enum on the outbound webhook (shipped when shippedAt IS NOT NULL, iterating otherwise).
  • Brief echoes the original IntakeRequest you POSTed — the canonical brand-name / logo / palette / voice / channels. Stamped null defensively on a malformed row (rows written before strict validation shipped) so the GET never 500s on a stale partner intake.
  • Missing 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.
Signature verification

The outbound callback now carries a signature header.

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.

Try it from your browser

Validate your partner key without leaving /partners.

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.

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.

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.

Become a Partner

Tell us what your agency ships.

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.

We'll come back inside one business day.

Next steps

Read /pricing for tier breakdown, or talk to us about becoming a Partner-tier agency — we'll come back inside one business day.