Developers
Ads by Breakfast API
Send a product or service page URL. Get 6 finished image ads with your real logo, your colors and copy from your own page. Async, credit based, versioned at /api/v1.
Quickstart
- Sign in at /account and create a test key. Test keys are free, never queue, and return 6 sample ads after about 20 seconds.
- Create a pack:
curl https://adsbybreakfast.com/api/v1/packs \
-H "Authorization: Bearer $ABB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: client-a-2026-10-05" \
-d '{
"url": "https://shop.example.com/products/cold-brew",
"format": "reels",
"webhook_url": "https://hooks.your-agency.com/abb",
"metadata": { "client": "client-a" }
}'HTTP/1.1 202 Accepted
{
"id": "pk_3f9c0d1e2a4b4c6d8e0f1a2b3c4d5e6f",
"object": "pack",
"status": "queued",
"credits_reserved": 6,
"eta": "2026-10-06T12:00:00.000Z",
"queue_position": 2,
"links": { "self": "/api/v1/packs/pk_3f9c0d1e2a4b4c6d8e0f1a2b3c4d5e6f" }
}- Wait for the
pack.readywebhook, or poll:
curl https://adsbybreakfast.com/api/v1/packs/pk_3f9c0d1e2a4b4c6d8e0f1a2b3c4d5e6f \
-H "Authorization: Bearer $ABB_API_KEY"{
"id": "pk_3f9c0d1e2a4b4c6d8e0f1a2b3c4d5e6f",
"object": "pack",
"status": "ready",
"format": "reels",
"url": "https://shop.example.com/products/cold-brew",
"created_at": "2026-10-05T23:10:04.000Z",
"eta": "2026-10-06T12:00:00.000Z",
"ready_at": "2026-10-06T08:41:57.000Z",
"metadata": { "client": "client-a" },
"ads": [
{
"index": 1,
"version": 1,
"image_url": "https://adsbybreakfast.com/api/v1/assets/packs/.../01.v1.png?exp=...&sig=...",
"width": 768,
"height": 1344,
"headline": "Cold brew that tastes like the cafe",
"subline": "Steeped 18 hours",
"cta": "Shop now",
"rebakes_used": 0,
"rebakes_left": 2,
"rebake_in_progress": false
}
],
"zip_url": "https://adsbybreakfast.com/api/v1/assets/packs/.../pack.zip?exp=...&sig=...",
"urls_expire_at": "2026-10-13T08:45:00.000Z"
}The same flow in JavaScript (Node 18+):
const BASE = "https://adsbybreakfast.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.ABB_API_KEY}`,
"Content-Type": "application/json",
};
// 1. Create the pack (safe to retry with the same Idempotency-Key)
const res = await fetch(`${BASE}/packs`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": "client-a-cold-brew-1" },
body: JSON.stringify({ url: "https://shop.example.com/products/cold-brew", format: "feed" }),
});
const pack = await res.json();
if (!res.ok) throw new Error(`${pack.error.code}: ${pack.error.message}`);
// 2. Poll (or use a webhook). Live packs are ready by 7am CT.
let p = pack;
while (!["ready", "failed"].includes(p.status)) {
await new Promise((r) => setTimeout(r, 10 * 60 * 1000));
p = await (await fetch(`${BASE}/packs/${pack.id}`, { headers })).json();
}
// 3. Use the results
for (const ad of p.ads) console.log(ad.index, ad.headline, ad.image_url);Auth
Send Authorization: Bearer abb_live_... or abb_test_.... Keys belong to your account and share its credits. We store only a hash, so a key is shown once when you create it. Revoke a key at /account and it stops working on the next request.
Live keys see live packs, test keys see test packs. Up to 10 active keys per account, one per client or workflow works well.
Endpoints
| Method and path | What it does |
|---|---|
POST/api/v1/packs | Create a pack. Body: url (required, public https), format reels (9:16, 768x1344, default) or feed (3:2, 1536x1024), count (6 only), webhook_url, metadata (up to 20 flat keys). Optional Idempotency-Key header. Returns 202 and reserves 6 credits. |
GET/api/v1/packs/{id} | Status: queued, cooking, needs_review (a person is checking it before it ships), ready, failed. When ready: 6 ads with signed image_url, headline, subline, cta and re-bake counts, plus zip_url. |
GET/api/v1/packs | Newest first. limit (1 to 100, default 20) and starting_after (a pack id). Returns data and has_more. |
POST/api/v1/packs/{id}/ads/{index}/rebake | New version of one ad. Body: reason (scene, literal, copy, wrong_claim, layout, product_size, logo, colors, other) and an optional note up to 200 characters. 2 free per ad, within 30 days of ready. |
GET/api/v1/rebakes/{id} | Re-bake status: queued, cooking, applied (new image_url and version), escalated (a person is on it), failed. |
GET/api/v1/account | Credits, credits reserved by packs in progress, and how many packs this key can still start tonight. |
Re-bake an ad
curl https://adsbybreakfast.com/api/v1/packs/pk_3f9c.../ads/4/rebake \
-H "Authorization: Bearer $ABB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "product_size", "note": "Product looks too small" }'Image and ZIP URLs are signed and expire after 7 days (urls_expire_at). Fetch the pack again for fresh links, and copy files somewhere you own instead of hotlinking. Copy, logo and claims always come from the page you send, so a page with thin copy makes thin ads.
Webhooks
Pass webhook_url on create and we POST each event to it: pack.queued, pack.cooking, pack.needs_review, pack.ready, pack.failed, rebake.applied, rebake.escalated. data is the same object the GET endpoints return.
POST https://hooks.your-agency.com/abb
ABB-Signature: t=1791273600,v1=5f0c...e91a
ABB-Event-Id: evt_8a1f0c2d3e4b5a6978c1d2e3
ABB-Event-Type: pack.ready
{
"id": "evt_8a1f0c2d3e4b5a6978c1d2e3",
"object": "event",
"type": "pack.ready",
"created": 1791273600,
"data": { "id": "pk_3f9c...", "object": "pack", "status": "ready", "ads": [ ... ], "zip_url": "..." }
}Every request is signed with the webhook secret shown when you created the key: v1 is the hex HMAC-SHA256 of t + "." + raw body. Check it before trusting the body:
import crypto from "node:crypto";
// Use the raw request body, before any JSON parsing.
export function verifyAbbSignature(rawBody, header, secret) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
if (!m) return false;
if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; // 5 minute window
const want = crypto.createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(want, "hex"), Buffer.from(m[2], "hex"));
}- Answer with any 2xx within 10 seconds. Anything else is retried after 1m, 5m, 15m, 1h, 3h, 6h, 12h and 24h, then dropped.
- Each event is sent once per pack and type. Use
ABB-Event-Idto skip repeats from retries. - Only public https URLs. We do not follow redirects.
Errors
Errors look like { "error": { "code": "insufficient_credits", "message": "..." } }. Branch on code, show message.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_body, invalid_format, invalid_count, invalid_webhook_url, invalid_metadata, invalid_idempotency_key, invalid_reason, invalid_cursor | Fix the request. |
| 400 | unsupported_field | A field v1 does not take (for example brief). Copy always comes from the page. |
| 401 | unauthorized | Missing or revoked key. |
| 402 | insufficient_credits | A pack needs 6 credits. The body has credits and need. |
| 403 | missing_scope, rebake_cap_reached, pack_rebake_cap_reached | Key lacks a scope, or the ad used its 2 free re-bakes. |
| 404 | pack_not_found, ad_not_found, rebake_not_found | Wrong id, or it belongs to another account or mode. |
| 409 | idempotency_conflict | Same Idempotency-Key, different body. |
| 409 | pack_not_ready, rebake_in_progress, pack_rebakes_in_progress, pack_recook_running | Wait and retry. |
| 410 | rebake_window_closed | Re-bakes close 30 days after the pack was ready. |
| 422 | url_not_public | url must be a public https page. |
| 429 | rate_limited | Too many requests. Honor Retry-After. |
| 429 | capacity_full_tonight, key_nightly_limit | Tonight is full for everyone, or for this key. Retry after next_slot_at. |
| 500 | internal_error | Our side. Retry with the same Idempotency-Key. |
A failed pack returns its credits automatically. error.code on a failed pack is one of url_unreachable, brand_unidentifiable, not_enough_distinct_claims or pack_failed.
Limits and credits
- 1 credit = 1 ad. A pack is 6 credits, reserved when we accept it and returned if it fails. Re-bakes inside the free 2 per ad cost nothing. Test keys cost nothing.
- Credits are shared with your account. Ask for API credit pricing from /account.
- Live packs are ready by 7am CT the morning after we accept them. We make a fixed number of packs each night. When tonight is full you get
429 capacity_full_tonightwithnext_slot_at. - Each live key starts up to 10 packs a night by default (
key_nightly_limit). Ask if you need a different limit. - 60 reads and 10 writes per minute per key. Over that:
429 rate_limitedwithRetry-After.
n8n
Import this workflow (Workflows, Import from File). It runs: Webhook (URL in), Create pack, Wait, Get pack, Status, Pack out, Deliver by email.
- Add a Header Auth credential: name
Authorization, valueBearer abb_test_.... Pick it in Create pack and Get pack. - Set your email credential and sender in Deliver by email, or swap that node for Drive, Slack or your CRM.
- Activate it and POST
{"url": "...", "format": "reels", "deliver_to": "[email protected]"}to its webhook URL.
The pack webhook goes to the execution's resume URL, so the workflow wakes when the pack is ready. If your n8n is not reachable from the internet, it also checks every 30 minutes, so it works either way. It reads the pack with your key on every wake and never trusts the webhook body.
MCP
Agents that speak MCP (Claude, ChatGPT, Cursor, VS Code) can use the API through our small MCP server. Tools: create_pack, get_pack, rebake_ad, list_packs, get_account. Ask us for the package, then run npm install in its folder and add it to your client:
{
"mcpServers": {
"ads-by-breakfast": {
"command": "node",
"args": ["/path/to/integrations/mcp-server/index.mjs"],
"env": { "ABB_API_KEY": "abb_test_..." }
}
}
}For HTTP instead of stdio: node index.mjs --http --port 8787 serves stateless Streamable HTTP at /mcp, and each request sends its own Authorization: Bearer abb_....
Versioning
Breaking changes get a new version path. New fields can appear in responses at any time, so ignore fields you do not know. Machine-readable spec: /api/v1/openapi.json.