Ads Placement API Reference
Everything below is the live production contract. One endpoint, five body-selected actions.
Base URL Onboarding Authentication & signing Actions Placement object Targeting reference Delivery & selection Billing Limits Error codes
Base URL
POST https://api.blasts.app/api/ping/ads/placement
HTTPS only. POST only — other methods return 405.
All requests and responses are application/json.
Onboarding
Partner accounts are provisioned manually — email AL@SavingsSites.com. You receive, exactly once:
| Credential | Used for |
|---|---|
api_key | The Authorization: Bearer header. Identifies your partner account. |
signing_secret | HMAC-SHA256 request signing (below). Never sent on the wire. |
Your account is configured with three negotiated settings: ads enabled (the gate for this API), a max payout weight ceiling, and a monthly send cap that bounds your post-paid exposure. Sandbox credentials are available on request for integration testing.
Authentication & request signing
Every request carries three headers:
| Header | Value |
|---|---|
Authorization | Bearer <api_key> |
X-Blasts-Timestamp | Unix time in milliseconds. Rejected if more than 5 minutes off server time (timestamp_expired). |
X-Blasts-Signature | sha256=<hex> — HMAC-SHA256 of the signing payload (below) keyed with your signing_secret. |
The signing payload is the timestamp and the exact raw request body joined by a single newline character:
payload = timestamp + "\n" + rawBody
signature = "sha256=" + hex( HMAC_SHA256( signing_secret, payload ) )
Serialize the JSON once, sign that exact string, and send
that exact string as the body. Re-serializing after signing (different key order, whitespace) makes
the signature invalid. The separator is a newline (\n), not a dot.
Optionally send Idempotency-Key (up to 128 characters) on create requests.
Replays with the same key return the original deal_id with "duplicate": true
instead of creating a second placement.
See Code Examples for working signing code in Node, Python, and cURL.
Actions
The JSON body always includes an action field. Successful responses are
200 with "ok": true; failures return "ok": false plus an
error code (see Error codes).
create Add a placement
{
"action": "create",
"deal": { … placement object, see below … }
}
Response:
{ "ok": true, "deal_id": "p_yourpartnerid_summer_promo", "ad": { …stored row… } }
The server namespaces your deal_id: whatever suffix you supply (lowercased,
a-z 0-9 _ -, max 48 chars) is stored as p_<partner_id>_<suffix>.
If you omit it, a timestamp-based suffix is generated. Use the returned deal_id in all
later calls. Duplicate ids return deal_id_exists.
update Modify a placement
{
"action": "update",
"deal_id": "p_yourpartnerid_summer_promo",
"deal": { "payout_weight": 80, "creative": { "copy": "New copy…" } }
}
A partial patch: supplied fields are merged onto the stored row
(creative merges field-by-field; targeting replaces wholesale), then the merged
object is re-validated end to end. Response mirrors create.
delete Retract a placement
{ "action": "delete", "deal_id": "p_yourpartnerid_summer_promo" }
Response: { "ok": true, "deal_id": "…", "deleted": true }. The placement leaves
rotation within about 60 seconds (server catalog cache TTL).
list Live placements + caps snapshot
{ "action": "list" }
{
"ok": true,
"partner_id": "yourpartnerid",
"count": 12,
"max_ads": 100,
"monthly_send_cap": 250000,
"month_to_date_sends": 48210,
"ads": [ { …placement rows… } ]
}
ledger Per-placement performance (billing source)
{ "action": "ledger" }
{
"ok": true,
"partner_id": "yourpartnerid",
"month": "2026-07",
"month_to_date_sends": 48210,
"monthly_send_cap": 250000,
"deals": [
{
"deal_id": "p_yourpartnerid_summer_promo",
"expires_at": 1722470400000,
"totals": { "sends": 9120, "views": 6011, "clicks": 402 },
"last_7_days": { "sends": 2210, "views": 1544, "clicks": 118 },
"month_to_date": { "sends": 9120, "views": 6011, "clicks": 402 }
}
]
}
Sends are metered server-side at append time and are authoritative. Views and clicks are reported best-effort by recipient clients — treat them as engagement signal, not an invoice line.
Placement object
{
"deal_id": "summer_promo",
"expires_at": 1722470400000,
"payout_weight": 50,
"price_usd": 9.99,
"main_category": "home-services",
"sub_category": "security",
"creative": {
"copy": "20% off first-year monitoring for new movers.",
"url": "https://advertiser.example.com/offer",
"audio_url": "https://cdn.example.com/spot.webm",
"audio_title": "New mover special — 30 second spot",
"audio_duration_ms": 30000
},
"snap_badge": { "shape": "pill", "color": "#1E1E1E", "label": "AD" },
"targeting": { … see targeting reference … }
}
| Field | Required | Rules |
|---|---|---|
expires_at | Yes | Unix milliseconds, integer, in the future, at most 30 days out. Expired placements drop from rotation automatically — refresh long-running campaigns by updating expires_at. |
creative.copy | Yes* | Plain text, max 500 characters. Shown as the ad body. |
creative.url | Yes* | HTTPS only, max 2048 characters. The click-through link. |
creative.audio_url | No* | HTTPS only. *An audio-only placement (no copy/url) is valid: the audio URL doubles as the link and the title becomes the copy. |
creative.audio_title | No | Max 120 characters. |
creative.audio_duration_ms | No | 1 to 600000 (10 minutes). |
payout_weight | No | Integer ≥ 0; default 0. Server-clamped to your account's max payout weight — sending a bigger number silently stores the ceiling. |
deal_id | No | Suffix only; server prefixes p_<partner_id>_. Lowercase a-z 0-9 _ -, max 48 chars. |
price_usd | No | 0.01 – 9999.99 (display price for the offer). |
main_category / sub_category | No | Free-form slugs (max 64) describing the advertiser. Used to keep multi-ad Blasts category-distinct. |
snap_badge | No | { shape, color, label } — label max 12 chars, defaults to "AD". The chip shown with the ad. |
targeting | No | Omit for run-of-network. See below. |
All creative text passes a family-safe content filter (copy, audio_title,
badge label). Rejected content returns content_not_allowed plus the offending
field.
Targeting reference
"targeting": {
"target_mode": "both",
"zips": ["07001", "07002"],
"geo_radius": [ { "zip": "07104", "miles": 25, "branch": "both" } ],
"resident_filters": {
"genders": ["female"],
"age_groups": ["under_36", "36_60"]
},
"business_filters": {
"org_sizes": ["solo", "2_10"],
"primary_roles": ["founder_owner"],
"target_markets": ["b2b"],
"business_categories": ["real_estate", "home_services"]
}
}
| Field | Accepted values |
|---|---|
target_mode | resident · business · both (default both) |
zips | Up to 5,000 five-digit US ZIP codes. Empty/omitted = no geo restriction. |
geo_radius | Up to 12 entries of { zip, miles, branch }. Miles 0.25–100. branch: resident · business · both. A single object (not array) is also accepted. |
resident_filters.genders | male · female |
resident_filters.age_groups | under_36 · 36_60 · 61_plus |
business_filters.org_sizes | solo · 2_10 · 11_50 · 50_plus |
business_filters.primary_roles | founder_owner · sales_marketing · operations · eng_it |
business_filters.target_markets | b2b · b2c · both |
business_filters.business_categories | Up to 24 slugs from the list below. |
Business category slugs:
software_technology, it_services_cybersecurity, marketing_advertising,
media_creative_design, legal_compliance, accounting_tax,
consulting_business_services, architecture_engineering, real_estate,
construction_trades, manufacturing, logistics_supply_chain,
agriculture_natural_resources, energy_utilities, automotive,
home_services, retail_ecommerce, food_beverage,
hospitality_travel, fitness_recreation, finance_insurance,
healthcare_medical, pharma_biotech, education_training,
nonprofit, government_public_sector, associations_membership,
telecommunications, other.
Filters combine with AND: a recipient must match the branch, the geo set (explicit ZIPs ∪ radius ZIPs), and every demographic filter you set. Unset filters match everyone. Targeting is evaluated entirely on our servers against the recipient's self-declared profile — no recipient data is ever returned to you.
Delivery & selection
- New and updated placements enter rotation within about 60 seconds (catalog cache TTL).
- When a Blast is delivered, all live placements matching the recipient compete; the highest
payout_weightwins the slot, with random tie-breaking among equal weights. - The ad is appended after the message body — clearly separated and badge-labeled. Audio spots render as a "Sponsored — audio from advertiser" block with a Listen player.
- If nothing matches, a house ad runs — you're never charged for unmatched traffic.
Billing
- Post-paid. No prepay, no per-call charge. Invoicing settles from the server-metered
sendsin yourledger. - Monthly send cap. When your month-to-date sends reach your cap, all your placements
pause automatically until the first of the next month (UTC).
listandledgerboth show cap and month-to-date so you can monitor pacing.
Limits
| Limit | Value |
|---|---|
| Live placements per partner | 100 |
| Max placement lifetime | 30 days (then refresh via update) |
| API rate limit | 100 requests/minute (default; negotiable). Exceeding returns 429 with retry_after_seconds. |
| Timestamp skew | ±5 minutes |
| Creative copy | 500 characters |
| URLs | HTTPS only, 2048 characters |
| Audio duration | 10 minutes |
Error codes
| HTTP | error | Meaning |
|---|---|---|
| 401 | missing_api_key / invalid_api_key | Bearer header absent or key not recognized. |
| 401 | invalid_signature / missing_signature | HMAC doesn't match the raw body + timestamp. Check the newline separator and that you signed the exact bytes sent. |
| 401 | timestamp_expired / invalid_timestamp | X-Blasts-Timestamp missing, non-numeric, or more than 5 minutes off. |
| 403 | partner_inactive | Account suspended — contact us. |
| 403 | ads_not_enabled | Your partner account isn't provisioned for the Ads API. |
| 429 | rate_limit_exceeded | Over your requests/minute. Retry after retry_after_seconds. |
| 409 | ads_cap_exceeded | Already at 100 live placements. Delete or let some expire. |
| 404 | deal_not_found | deal_id doesn't exist under your account (or already expired and was pruned). |
| 400 | expires_at_required / expires_at_in_past / expires_at_too_far | Expiry missing, not a future ms-epoch integer, or beyond 30 days. |
| 400 | invalid_deal | Creative incomplete — needs copy + HTTPS url, or an HTTPS audio_url. |
| 400 | content_not_allowed | Creative failed the content filter; response includes the field. |
| 400 | deal_id_exists | create with a suffix you already used. Use update or a new suffix. |
| 400 | invalid_action / malformed_json / deal_id_required / deal_object_required | Request shape problems. |