Audience: Claude Code, implementing the push layer of the v2 rebuild. Purpose: everything needed to create campaigns and upload images against the Hashbun backend, plus how to discover anything not documented here directly from the live API.
Secrets: do not commit any bearer token to the repo. The token is short-lived and operator-supplied (see §2). This document contains no token.
- API base:
https://api.hashbunmedia.com - Public site / page base:
https://hashbunmedia.com - All endpoints below are relative to the API base.
- The backend stores uploaded images in Cloudinary (cloud name
dq8lvevhz); image responses return a Cloudinary URL. You never call Cloudinary directly — you call the Hashbun image endpoint and it handles Cloudinary.
- Auth is a Bearer token in the
Authorizationheader:Authorization: Bearer <token>. - Required for: creating campaigns, uploading images.
- Not required for: reading campaigns and URI checks (those endpoints are public — very useful for discovery, see §7).
- The token is short-lived. When a request returns HTTP 401, the token has expired or been replaced — the operator refreshes it from the browser: open the Hashbun admin, DevTools → Network tab → pick any request to
api.hashbunmedia.com→ copy the value afterBearerin theauthorizationrequest header. - Store the token in operator-owned config outside the repo (env var or a git-ignored config file). Never hard-code it.
POST /campaign/
Authorization: Bearer <token>
Content-Type: application/json
Body (payload) — field-by-field:
| Field | Type | Notes |
|---|---|---|
title |
string | The campaign title (the detected heading). |
uri |
string | The unique slug (see 03_POC.md §7). Must be unique — check first (§5). |
isPublished |
bool | true = live. Keep a config flag to push drafts (false) during testing. |
content |
string (HTML) | Body HTML fragment (<p>, <strong>, <a>, <ul><li>). |
formTitle |
string | The form sub-heading (e.g. "Please fill in the form…"). |
submitButtonTitle |
string | Submit button label (e.g. "Download" / "Herunterladen"). |
fields |
array | The form fields — see §4. |
privacySettings |
object | { "type": "disabled", "label": null, "target": null, "url": null } (default). |
termSettings |
object | { "type": "disabled", "label": null, "target": null, "url": null } (default). |
formSuccessMessage |
string | Default: "Success! Your download will begin shortly." |
snackSuccessMessage |
string | Default: "Thank you" |
submitURL |
string/null | Default null. |
customFieldsLayout |
bool | Default false. |
noOfLayoutRows |
int/null | Default null. |
styles |
string/null | Campaign CSS (default stylesheet). |
campaignType |
string | Default "general". |
emailDesign |
object | Default { "content": null, "emailFieldCode": null, "subject": null }. |
externalAPI |
array | Default []. |
image |
string/null | The hero image id (_id returned by the image upload, §6). |
logo |
string/null | The logo image id (_id from image upload). |
resource |
string/null | Default null. |
Response: the created campaign object, including _id and uri. On success capture both; the live page is https://hashbunmedia.com/<uri> (confirm exact path against a known campaign — see §7).
On failure: log status_code and the full response body. A 401 = token (see §2). A 4xx about the URI usually means it already exists — resolve uniqueness first (§5).
Each entry in fields has this shape:
{
"name": "First name",
"code": "first_name",
"type": "TextBox",
"isMandatory": true,
"isVisible": true,
"defaultValue": "",
"fieldValues": [],
"rowNumber": null,
"associatedField": null,
"conditionalField": null,
"conditionalValue": null
}typeis one of:"TextBox","Email","DropDown","CheckBox".codeis a stable, unique, snake_case key derived from the label. Guarantee uniqueness within the form (suffix_2,_3on collision).fieldValuesis used only byDropDown. Each option is:(Country dropdowns are populated from the global country list; codes are the list's codes as strings.){ "code": "1", "value": "United States", "associatedValue": "" }CheckBox(consent):type="CheckBox",name= the consent HTML (sentence + inline<a href=…>privacy link</a>), no<p>wrapper,fieldValuesempty. Exactly one consent per form (merge a stray link-only line back in).Email: a text field whose label looks like an email/e-mail/courriel;fieldValuesempty.- Leave
rowNumber,associatedField,conditionalField,conditionalValueasnullunless the layout genuinely needs them.
GET /campaign/uri/{uri}
- 404 → the URI is free (use it).
- 200 → a campaign with that URI already exists (returns its data). Resolve by appending a dated suffix and re-checking (see
03_POC.md§7). - Public (no auth needed), so safe to call repeatedly for live uniqueness feedback in the UI.
POST /image/
Authorization: Bearer <token>
Content-Type: multipart/form-data
Multipart fields:
imageType:"Logo"for the logo,"Campaign"for the hero.altText: the image name (use the unique filename stem, e.g. the slug for the hero,logo-<random>for the logo).image: the file bytes. The multipart filename must be unique per campaign (e.g.<slug>.png) — identical filenames across campaigns previously collided on the backend/Cloudinary and broke every image after the first.
Response (JSON):
{
"url": "https://res.cloudinary.com/dq8lvevhz/.../<name>.png",
"publicID": "…",
"altText": "…",
"width": 890,
"height": 1254,
"_id": "6a3c…"
}Use the returned _id as the image / logo value in the create payload (§3). Log the status, url, and _id for every upload.
Order of operations for a push: upload hero → capture _id → upload logo → capture _id → set image/logo on the payload → POST /campaign/. If an image upload fails, log it and either abort that campaign or create without the image per operator choice — never fail silently.
The read endpoints are public, so Claude Code (or the operator) can inspect the real system to confirm any detail or find fields this document doesn't cover.
-
List / inspect existing campaigns:
GET /campaign/Returns the full set of existing campaigns (there are ~1300+). Inspect a few real objects to confirm the exact payload shape, default values, and any field not listed here. This is the source of truth — the schema in §3/§4 was derived from it.
-
Fetch one known-good campaign by URI:
GET /campaign/uri/{some-existing-uri}Pick a campaign you know renders correctly on the site, fetch it, and mirror its structure. Especially useful for confirming how
fields,styles, consentCheckBoxHTML, andimage/logoreferences are stored. -
Confirm the image contract with a HAR capture: in the Hashbun admin, create/edit a campaign with the browser DevTools → Network recording. Look at the real
POST /image/andPOST /campaign/requests to confirm headers, multipart field names, and body — then match them exactly. (This is how the image endpoint'simageType/altTextcontract was originally confirmed.) -
When adding a new capability (e.g. conditional fields, a new field type, term/privacy settings that are actually enabled), first
GETa real campaign that uses it and copy its structure rather than guessing.
Rule of thumb: if the API behaves differently from this document, believe the live API and update the implementation to match a real, working campaign object.
- 401 anywhere → expired/replaced token; prompt the operator to refresh it (§2). Both image upload and create use the same token, so both clear together once it's valid.
- URI already exists → append dated suffix, re-check
GET /campaign/uri/{uri}until 404. - Image upload non-2xx → log status + body; do not attach; surface in the UI.
- Country field but empty global list → block the campaign (do not create).
- Timeouts → use a sane request timeout (e.g. 30s) and log; a network failure on the uniqueness check should skip that campaign, not crash the batch.