API Reference
Affset REST API documentation
Automate core ad operations over HTTP: manage campaigns and zones, query performance stats, configure targeting and payouts, and integrate ad serving. Includes practical cURL requests and response examples.
- Base URL · https://api.affset.com
- Auth · Bearer + X-Namespace
- Format · JSON + redirects
Prefer plain language over code? Affset also ships an MCP server that puts this same API behind a chat client.
Building a client or an agent? This reference is also published as an OpenAPI 3.0 spec and as Markdown, both generated from the same source as this page.
On this page
Getting started
The authenticated management API uses JSON; successful deletes return an empty 204 response. Public ad-serving endpoints return redirects, HTML, plain text, a tracking GIF, or JSON as described below. There's no separate developer signup — every tenant already has access, and the dashboard's Team page manages the same API-key model used here.
Authentication
Every authenticated management endpoint below needs two headers. The Ad serving endpoints are public and do not use either one:
Authorization: Bearer <api_key>
X-Namespace: <namespace>A key is scoped to one namespace when it's created — X-Namespace has to match it or the request is rejected. That's what keeps tenants isolated at the network boundary, not just in the dashboard. Get a key from your dashboard's Team page, or mint one via the API itself once you have one (see Team below).
| Role | Access |
|---|---|
| owner | Full access to everything. The only role that can permanently delete the tenant. |
| manager | Same day-to-day access as owner — campaigns, zones, team, payouts, targeting. Can’t delete the tenant. |
| advertiser | Manages their own campaigns and can read zones. Sees campaign spend, but not publisher payout, media cost, or ROI. |
| advertiser_manager | Manages campaigns for assigned advertisers and can add advertisers to their own team. Uses the same financial redaction as advertiser. |
| publisher | Manages their own zones and has no campaign access. Sees payout, media cost, and ROI, but not advertiser spend. |
| publisher_manager | Manages zones for assigned publishers and can add publishers to their own team. Uses the same financial redaction as publisher. |
Every key also carries permissions — usually ["read","write"]. A read-only key can call GET endpoints but gets a 403 on anything that writes, regardless of role.
The hosted MCP server maps its OAuth scopes onto the same permissions — each grant is backed by a key issued with exactly what the scope implies (read → ["read"], full → ["read","write"]), so this RBAC applies unchanged. Machine-readable: RFC 9728 metadata with scopes_supported.
GET/api/meVerify a key and see what it can do
curl "https://api.affset.com/api/me" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"namespace": "acme-media",
"user_id": "usr_8f2a1c",
"email": "buyer@example.com",
"role": "advertiser",
"permissions": [
"read",
"write"
],
"capabilities": {
"campaigns": true,
"zones": true,
"assign_campaign_user": false,
"assign_zone_user": false,
"tenant_management": false
}
}- The response also includes capabilities, a role-derived summary of what this key can do — handy for building conditional UI without hard-coding the role table above.
Errors
Errors are JSON: { "error": "human-readable message" }. A few responses attach extra machine-readable fields on top of that shape — notably plan-limit responses, below.
| Status | Meaning |
|---|---|
| 400 | Bad Request — missing header, parameter, or body, or an invalid value. |
| 401 | Unauthorized — invalid or expired key, or X-Namespace doesn't match it. |
| 402 | Payment Required — you’re at a plan limit. See below. |
| 403 | Forbidden — your role or permissions don't allow this. Also used to mask ownership on writes. |
| 404 | Not Found — the resource doesn't exist, or isn't visible to your role. |
| 409 | Conflict — something with the same identity already exists. |
| 422 | Unprocessable Entity — the request is well-formed but cannot be processed (e.g. a CSV export that exceeds the row cap). |
| 500 | Internal Server Error — something went wrong on our end. |
Plan limits (402) are returned when an action would exceed your plan — activating a campaign, creating a zone, inviting a team member, adding a machine API key, or setting a custom API domain:
{
"error": "Plan limit reached for campaigns",
"code": "PLAN_LIMIT_REACHED",
"dimension": "campaigns",
"limit": 10,
"current": 10,
"plan_id": "free",
"min_plan_id": "starter"
}The public ad-serving endpoints (/serve, /track/click, /px) are plain text on error, not JSON — see Ad serving.
Pagination
Endpoints that list a tenant-wide resource — campaigns, zones, conversions — accept limit (default 20, max 100) and offset (default 0), and wrap results in a pagination block:
{
"campaigns": [
"…"
],
"pagination": {
"total": 42,
"limit": 20,
"offset": 0,
"has_more": true
}
}Smaller, scoped lists — a campaign's targeting rules, its payout rules, the targeting-type catalog — return the full set with no pagination, since they're naturally bounded. GET /api/api-keys is the one exception: it returns a bare array with no wrapper at all.
Rate limits
The authenticated management endpoints in this reference have no general hard rate limit today. Please still cache Stats responses instead of polling in a loop, and use pagination instead of fetching one record at a time. Planning something high-volume? Tell us first so it goes smoothly.
Tenant settings
Branding, timezone, sub-label names, and a couple of serving behaviors for your account.
GET/api/tenantRead tenant settings
curl "https://api.affset.com/api/tenant" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"company": "Acme Media",
"timezone": "America/New_York",
"primary_color": "#4F46E5",
"secondary_color": "#14161C",
"custom_api_domain": "api.acme-media.com",
"sub_labels": {
"sub1": "Zone",
"sub2": "Creative"
},
"redirect_method": "html",
"email": "owner@acme-media.com",
"feature_flags": {}
}- There’s also an unauthenticated GET /api/public/tenant (X-Namespace header only, no API key) that returns just company, primary_color and secondary_color — enough to brand a public-facing page.
PUT/api/tenantUpdate tenant settings
Body
| Name | Type | Description |
|---|---|---|
| company | string | Display name shown in the dashboard and emails. |
| timezone | string | IANA timezone, e.g. "America/New_York". Drives Stats date bucketing and campaign date-only schedules. |
| primary_color | string | Hex color, e.g. "#4F46E5". Must match #RRGGBB. |
| secondary_color | string | Hex color, e.g. "#14161C". Must match #RRGGBB. |
| custom_api_domain | string | Domain used in generated /serve and /track/click links instead of the default API host. Setting a non-empty value is plan-gated; clearing it is always free. |
| sub_labels | object | Partial update of sub1–sub5 display names — see below. Only the keys you send are touched. |
| redirect_method | "html" | "3xx" | How the /serve → /track/click hop is delivered — see Ad serving. |
curl -X PUT "https://api.affset.com/api/tenant" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"sub_labels":{"sub1":"Zone","sub3":null},"redirect_method":"3xx"}'{
"company": "Acme Media",
"timezone": "America/New_York",
"redirect_method": "3xx",
"sub_labels": {
"sub1": "Zone"
},
"updated_at": 1753887600000
}- At least one field is required.
- sub_labels is a merge, not a replace — send only the keys you want to change. null or "" clears that slot. Max 40 characters per label; an unrecognized key like sub6 is a 400, not a silent no-op.
- Setting a non-empty custom_api_domain is plan-gated and can return 402; clearing it is always free.
Stats
Aggregated performance, grouped by one dimension per call.
GET/api/statsGrouped traffic and conversion stats
Query parameters
| Name | Type | Description |
|---|---|---|
| from | epoch ms | Default: start of today, UTC. |
| to | epoch ms | Default: now. |
| group_by | enum | date (default) | campaign_id | zone_id | country | conversion_type | status | publisher_email | advertiser_email | sub1…sub5. |
| campaign_ids | comma-separated | Restrict to these campaigns. |
| zone_ids | comma-separated | Restrict to these zones. |
| publisher_manager_email | string | Restrict to zones owned by publishers assigned to this manager. Owner/manager may use any manager email; publisher_manager may use only their own. |
| advertiser_email | string | Narrow every row to one advertiser’s campaigns, independent of group_by. Owner/manager may use any advertiser email; advertiser_manager only one of their own assigned advertisers. |
| publisher_email | string | Narrow every row to one publisher’s zones, independent of group_by. Owner/manager may use any publisher email; publisher_manager only one of their own assigned publishers. |
| sub1…sub5 | string | A value, a comma list, or an empty string to match rows where that sub is unset. |
| conversion_type | string | Filter to specific conversion goal types — a value, a comma list, or an empty string to match conversions recorded without a type. Returns conversion rows only, so impressions, clicks and click-derived cost are zero in this slice. |
| status | comma-separated | Conversion lifecycle filter: any comma list of pending, approved, rejected. Like conversion_type, this describes conversion rows only — impressions, clicks and click-derived cost are zero. |
| paid_only | boolean | true drops informative conversions (recorded with postback_skipped: non_goal_type and $0 money) from the conversions count — the same switch as on /api/conversions. Default false. Recent (raw) rows only: days already folded into the archive keep informative rows in their count. Money sums are unaffected either way. |
curl "https://api.affset.com/api/stats?group_by=zone_id&campaign_ids=42" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"stats": [
{
"zone_id": "b6e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b11",
"zone_name": "RichAds Push",
"impressions": 18400,
"fallbacks": 210,
"unsold": 40,
"clicks": 512,
"conversions": 9,
"spend": 12.8,
"payout": 27.5,
"media_cost": 14.2,
"roi": 0.9366
}
],
"period": {
"from": 1753747200000,
"to": 1753833600000
},
"sub_labels": {
"sub1": "Zone"
}
}- group_by=publisher_email needs owner, manager, or publisher_manager (scoped to their assigned publishers); group_by=advertiser_email needs owner, manager, or advertiser_manager (scoped to their assigned advertisers). The row key is the current zone/campaign owner — reassigning a zone or campaign re-attributes its history.
- advertiser_email and publisher_email are also standalone filters (not just group_by values): passed as their own query params they narrow every row to one advertiser/publisher regardless of what group_by is set to, instead of breaking every user out into its own row. Same RBAC as the matching group_by.
- group_by=status breaks conversions down by review state (pending | approved | rejected — the lifecycle behind hold windows and manual review). Like group_by=conversion_type, a status breakdown or filter covers conversion rows only, so impressions, clicks and click-derived cost are zero there.
- from and to are UTC epoch milliseconds. With group_by=date, each returned date label is a calendar date in your tenant’s timezone (set via PUT /api/tenant).
- Two different costs show up here: spend is Affset’s own campaign accounting — CPM campaigns accrue it per click at the campaign’s rate, CPA campaigns accrue it on conversion instead. media_cost is your actual traffic cost — the cost= click-time estimate from /serve or /track/click, except on the unfiltered group_by=date view, where complete days (and the current day-to-date) covered by traffic-source cost sync use the network’s own synced spend instead; partial historical days keep the estimate. The covered zones’ estimate is replaced, never added on top, and the synced_cost field on those rows shows the synced component. roi is computed from payout and media_cost, not spend, since that’s the number a media buyer is optimizing against — and it’s null (not 0) when there’s no cost data for that row.
- Publisher-side roles (publisher, publisher_manager) never see spend. Advertiser-side roles (advertiser, advertiser_manager) never see payout, media_cost, or roi. Same redaction applies to Conversions.
Campaigns
Owner and manager see every campaign. advertiser and advertiser_manager are scoped to their own or managed advertisers. publisher and publisher_manager get 403 on this entire tree.
GET/api/campaignsList campaigns
Query parameters
| Name | Type | Description |
|---|---|---|
| status | enum | active | paused | archived. Omit for all statuses. |
| limit | integer | 1–100, default 20. |
| offset | integer | Default 0. |
| sort | enum | name | created_at (default) | start_date. |
| order | enum | asc | desc (default). |
curl "https://api.affset.com/api/campaigns?status=active" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"campaigns": [
{
"id": 42,
"name": "BR Sweepstakes — Push",
"status": "active",
"redirect_url": "https://offer.example/lp?s={click_id}",
"redirect_urls": [
"https://offer.example/lp?s={click_id}"
],
"payment_model": "cpa",
"rate": 0,
"payout_goal_type": null,
"daily_budget": null,
"total_budget": null,
"pacing": "asap",
"start_date": 1753747200000,
"end_date": null,
"user_email": "buyer@example.com",
"created_at": 1753747200000
}
],
"pagination": {
"total": 7,
"limit": 20,
"offset": 0,
"has_more": false
}
}- There’s no server-side name search — filter the page client-side if you need it.
GET/api/campaigns/{campaign_id}Get one campaign
curl "https://api.affset.com/api/campaigns/42" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"id": 42,
"name": "BR Sweepstakes — Push",
"status": "active",
"redirect_url": "https://offer.example/lp?s={click_id}",
"redirect_urls": [
"https://offer.example/lp?s={click_id}",
"https://offer.example/lp-b?s={click_id}"
],
"payment_model": "cpa",
"rate": 0,
"user_email": "buyer@example.com",
"targeting_rules": [
{
"id": 501,
"targeting_rule_type_id": 1,
"targeting_method": "whitelist",
"rule": "BR,MX"
}
]
}- 404 for a campaign that doesn’t exist or isn’t visible to your role — GET never reveals which.
POST/api/campaignsCreate a campaign
Body
| Name | Type | Description |
|---|---|---|
| name * | string | Campaign name. |
| redirect_url * | string | Must be http(s). See Ad serving for the macros it can contain. Either this or redirect_urls is required. |
| redirect_urls | string[] | 1–10 http(s) URLs; clicks are split randomly between them (prelander rotation). Wins over redirect_url when both are sent. Repeat a URL to give it a bigger share. |
| user_email | string | Required for owner, manager and advertiser_manager — whose advertiser this bills to. Advertisers may omit it (defaults to themselves). |
| payment_model | "cpm" | "cpa" | Default cpm. |
| rate | number | Default 0. Rounded to 2 decimals. |
| payout_goal_type | string | null | Only conversions whose pixel type= matches this exactly accrue spend/payout — others record at $0. |
| silent | integer | 0 disables silent conversions. A positive N makes every Nth conversion pay $0 and skip the affiliate postback; requires the silent_conversions feature flag. |
| daily_budget / total_budget | number | null | 0–999999999.99999. |
| pacing | "asap" | "even" | Default asap. |
| start_date / end_date | epoch ms | Optional. |
| targeting_rules | array | Optional shortcut: [{ targeting_rule_type_id, targeting_method, rule }] — same shape as the Targeting sync endpoint. Convenient for geo at creation time; everything else, use Targeting after creating. |
curl -X POST "https://api.affset.com/api/campaigns" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"name":"BR Sweepstakes — Push","redirect_url":"https://offer.example/lp?s={click_id}","user_email":"buyer@example.com","payment_model":"cpa","targeting_rules":[{"targeting_rule_type_id":1,"targeting_method":"whitelist","rule":"BR,MX"}]}'{
"id": 42,
"name": "BR Sweepstakes — Push",
"redirect_url": "https://offer.example/lp?s={click_id}",
"redirect_urls": [
"https://offer.example/lp?s={click_id}"
],
"status": "paused",
"payout_goal_type": null,
"silent": 0,
"created_at": 1753747200000
}- New campaigns are always created paused — run it with PUT once it’s ready (see Update below).
PUT/api/campaigns/{campaign_id}Update a campaign (partial)
Body
| Name | Type | Description |
|---|---|---|
| name | string | New campaign name. |
| redirect_url | string | New http(s) destination; supports the macros listed under Ad serving. Replaces the whole rotation set with this one URL. |
| redirect_urls | string[] | 1–10 http(s) URLs; clicks are split randomly between them. Replaces the existing set; wins over redirect_url when both are sent. |
| status | "active" | "paused" | "archived" | Use this to run/pause/archive a campaign. |
| payment_model | "cpm" | "cpa" | How campaign spend is calculated. |
| rate | number | Non-negative; rounded to 2 decimals. |
| payout_goal_type | string | null | Send null or an empty string to clear the goal filter. |
| silent | integer | Non-negative silent-conversion cadence; 0 disables it. A positive value requires the silent_conversions feature flag. |
| daily_budget / total_budget | number | null | Send null to clear a budget. |
| pacing | "asap" | "even" | Delivery pacing; even works against the daily budget. |
| start_date / end_date | epoch ms | null | Send null to clear a boundary. |
| user_email | string | advertiser_email works identically — both write the same field. |
curl -X PUT "https://api.affset.com/api/campaigns/42" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"status":"active"}'{
"id": 42,
"updated_at": 1753887600000
}- Only the fields you send are changed — this is a partial update despite the PUT verb. An empty/unrecognized body is a 400.
- Setting status to active runs your plan’s active-campaign check and can return 402.
- Response is just { id, updated_at } — re-fetch with GET if you need the full row back.
DELETE/api/campaigns/{campaign_id}Delete a campaign
curl -X DELETE "https://api.affset.com/api/campaigns/42" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"- Cascades its targeting rules. Conversions and click history are not deleted with it.
Zones
Owner, manager, publisher and publisher_manager can create/update zones — publisher and publisher_manager scoped to their own or managed publishers. advertiser and advertiser_manager have read-only access.
GET/api/zonesList zones
Query parameters
| Name | Type | Description |
|---|---|---|
| status | enum | active | inactive. |
| limit | integer | 1–100, default 20. |
| offset | integer | Default 0. |
| sort | enum | name | created_at (default) | site_url. |
| order | enum | asc | desc (default). |
curl "https://api.affset.com/api/zones?status=active" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"zones": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "RichAds Push",
"status": "active",
"site_url": null,
"traffic_back_url": "https://richads.example/fallback",
"postback_url": "https://richads.example/pb?click_id={source_click_id}&payout={payout}",
"traffic_source_id": null,
"traffic_source_name": null,
"user_email": "publisher@example.com",
"manager_email": null,
"created_at": 1753747200000
}
],
"pagination": {
"total": 3,
"limit": 20,
"offset": 0,
"has_more": false
}
}- manager_email is computed, not stored — it’s the publisher_manager who owns that zone’s publisher, if any.
GET/api/zones/{zone_id}Get one zone
curl "https://api.affset.com/api/zones/550e8400-e29b-41d4-a716-446655440000" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "RichAds Push",
"status": "active",
"postback_url": "https://richads.example/pb?click_id={source_click_id}&payout={payout}",
"traffic_source_id": null,
"traffic_source_name": null
}POST/api/zonesCreate a zone
Body
| Name | Type | Description |
|---|---|---|
| name * | string | Zone name. |
| site_url | string | Must be http(s) if present. |
| traffic_back_url | string | Where /serve sends traffic when there’s no eligible campaign. Must be http(s) if present. |
| postback_url | string | Affiliate passback — Affset GETs this on conversion. Macros: {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Must be http(s) if present. |
| traffic_source_id | string | Optional. Must reference a traffic source in this tenant; otherwise 400. Zone reads then include the joined traffic_source_name — see Traffic Sources. |
| user_email | string | Publishers can only create for themselves. Owner/manager/publisher_manager may assign any publisher. |
curl -X POST "https://api.affset.com/api/zones" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"name":"RichAds Push","postback_url":"https://richads.example/pb?click_id={source_click_id}&payout={payout}"}'{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "active",
"created_at": 1753747200000
}- Zones are always created active.
- Creating a zone counts against the plan’s zone limit and can return 402.
- Affset doesn’t require {source_click_id} (or the legacy {sub_id}) in postback_url, but a postback without it can’t be matched back to a click — you’ll get a warning, not a rejection.
- traffic_back_url is only the /serve fallback when no campaign can deliver — it is not a conversion postback.
PUT/api/zones/{zone_id}Update a zone (partial)
Body
| Name | Type | Description |
|---|---|---|
| name | string | New zone name. |
| status | "active" | "inactive" | Unlike campaigns, there’s no archived state for zones. |
| site_url / traffic_back_url / postback_url | string | null | Send null to clear a URL. |
| traffic_source_id | string | null | Link to a traffic source in this tenant, or send null / "" to unlink. |
curl -X PUT "https://api.affset.com/api/zones/550e8400-e29b-41d4-a716-446655440000" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"postback_url":null}'{
"id": "550e8400-e29b-41d4-a716-446655440000",
"updated_at": 1753887600000
}DELETE/api/zones/{zone_id}Delete a zone
curl -X DELETE "https://api.affset.com/api/zones/550e8400-e29b-41d4-a716-446655440000" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"Team
Team members and machine API keys share one endpoint, distinguished by type. There’s no separate /api/team.
GET/api/api-keysList team members or machine keys
Access: Owner and manager see the whole tenant. publisher_manager / advertiser_manager see only their own assigned publishers/advertisers.
Query parameters
| Name | Type | Description |
|---|---|---|
| type * | "user" | "api-key" | user = people (dashboard/API logins). api-key = machine keys with no owning person. |
curl "https://api.affset.com/api/api-keys?type=user" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"[
{
"token": "<redacted — the live response contains the bearer token>",
"namespace": "acme-media",
"user_id": "usr_8f2a1c",
"email": "buyer@example.com",
"role": "advertiser",
"created_at": 1753747200000,
"permissions": [
"read",
"write"
]
}
]- Returns a bare array — no pagination envelope, unlike every other list endpoint.
POST/api/api-keysInvite a team member / issue a machine key
Access: Owner and manager can create any role. publisher_manager can only create publisher (assigned to themselves). advertiser_manager can only create advertiser.
Query parameters
| Name | Type | Description |
|---|---|---|
| type * | "user" | "api-key" | Use user for a person with an email, or api-key for a machine credential. |
Body
| Name | Type | Description |
|---|---|---|
| string | Required when type=user, ignored for api-key. | |
| role * | enum | owner | manager | publisher | advertiser | advertiser_manager | publisher_manager. |
| permissions | string[] | Any of read, write. Default ["read","write"]. |
| manager_email | string | Only valid for type=user when role is publisher or advertiser. |
| expires_at | epoch ms | Optional future expiration time. |
curl -X POST "https://api.affset.com/api/api-keys?type=user" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"email":"sarah@offer.com","role":"publisher"}'{
"token": "<new live bearer token>",
"namespace": "acme-media",
"email": "sarah@offer.com",
"role": "publisher",
"permissions": [
"read",
"write"
],
"created_at": 1753747200000
}- The plaintext token is returned here, by rotate, and by the list endpoint to permitted roles. Treat all three responses as secrets and avoid logging them.
- This creates the key directly, like the dashboard’s "Add team member" — it does not send an invite email. Hand the token to the person yourself, over a channel you trust.
- type=user is gated by the seat limit (402) and requires a verified tenant email address (403, code EMAIL_VERIFICATION_REQUIRED). type=api-key uses the separate machine-key plan limit.
DELETE/api/api-keysRevoke a team member or key
Body
| Name | Type | Description |
|---|---|---|
| token * | string | Bearer token to revoke. |
| action * | "revoke" | Must be revoke. |
curl -X DELETE "https://api.affset.com/api/api-keys" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"token":"sk_live_...","action":"revoke"}'- Sets the key’s expiry to now. For a user key, it also pauses campaigns and deactivates zones owned by that email. History stays intact.
DELETE/api/api-keysPermanently remove a team member or key
Body
| Name | Type | Description |
|---|---|---|
| token * | string | Already-revoked bearer token to remove. |
| action * | "remove" | Must be remove. |
curl -X DELETE "https://api.affset.com/api/api-keys" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"token":"sk_live_...","action":"remove"}'- Hard-deletes an expired key. For a user key, it also deletes campaigns and zones owned by that email; removing a machine key deletes only the credential. Revoke an active key first.
PATCH/api/api-keysRotate a token
Body
| Name | Type | Description |
|---|---|---|
| token * | string | Active bearer token to replace. |
| action * | "rotate" | Must be rotate. |
curl -X PATCH "https://api.affset.com/api/api-keys" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"token":"sk_live_...","action":"rotate"}'{
"token": "<new live bearer token>",
"role": "publisher",
"permissions": [
"read",
"write"
]
}- Issues a new token for the same identity and invalidates the old one immediately.
PATCH/api/api-keys?type=userReassign who manages this person
Body
| Name | Type | Description |
|---|---|---|
| token * | string | Bearer token belonging to the person being reassigned. |
| manager_email | string | null | The publisher_manager/advertiser_manager they report to. null clears it. |
curl -X PATCH "https://api.affset.com/api/api-keys?type=user" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"token":"sk_live_...","manager_email":"manager@acme-media.com"}'{
"user_id": "usr_8f2a1c",
"manager_email": "manager@acme-media.com"
}DELETE/api/api-keysClose the account
Access: Owner only.
Body
| Name | Type | Description |
|---|---|---|
| token * | string | Owner API key or active owner session token. |
| action * | "terminate" | Must be terminate. |
curl -X DELETE "https://api.affset.com/api/api-keys" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"token":"sk_live_...","action":"terminate"}'Payout rules
Same campaign-scoping as Campaigns above. A campaign can have one global rule and one rule per zone.
GET/api/campaigns/{campaign_id}/payout_rulesList a campaign’s payout rules
curl "https://api.affset.com/api/campaigns/42/payout_rules" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"payout_rules": [
{
"id": 12,
"campaign_id": 42,
"zone_id": null,
"payout": 2.5,
"created_at": 1753747200000
},
{
"id": 13,
"campaign_id": 42,
"zone_id": "550e8400-e29b-41d4-a716-446655440000",
"payout": 3,
"created_at": 1753747200000
}
]
}POST/api/campaigns/{campaign_id}/payout_rulesCreate a payout rule
Body
| Name | Type | Description |
|---|---|---|
| payout * | number | 0.00001–9999.99999. |
| zone_id | string | Omit for the global (fallback) rule. |
curl -X POST "https://api.affset.com/api/campaigns/42/payout_rules" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"payout":3,"zone_id":"550e8400-e29b-41d4-a716-446655440000"}'{
"id": 13,
"campaign_id": 42,
"zone_id": "550e8400-e29b-41d4-a716-446655440000",
"payout": 3,
"created_at": 1753747200000
}- Resolution at conversion time: zone-specific rule, then the global rule, then $0.
- 409 if a rule already exists for that exact campaign + zone (or campaign + global) — see "Changing a payout" below.
DELETE/api/campaigns/{campaign_id}/payout_rulesDelete a payout rule
Query parameters
| Name | Type | Description |
|---|---|---|
| zone_id | string | Omit to delete the global rule. |
curl -X DELETE "https://api.affset.com/api/campaigns/42/payout_rules" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"Traffic Sources
Owner, manager, advertiser and advertiser_manager can manage traffic sources — same scoping as Campaigns above. publisher and publisher_manager get 403 on this entire tree. The stored api_token is write-only: every read returns has_api_token instead. Zones link to a source via traffic_source_id on POST/PUT /api/zones (see Zones above); zone reads then include the joined traffic_source_name. Sources with an api_token and an exoclick or trafficstars preset get cost sync: an hourly job pulls the network’s own daily spend (re-syncing the last three days), and the unfiltered group_by=date Statistics view prefers those numbers for complete days and the current day-to-date over the cost= click-time estimate — link your zones to the source so the estimate is replaced rather than added on top.
GET/api/traffic-source-presetsList traffic source presets
curl "https://api.affset.com/api/traffic-source-presets" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"presets": [
{
"id": "exoclick",
"name": "ExoClick",
"doc_url": "https://docs.exoclick.com/advertisers/campaigns/macros",
"tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}&sub2={site_id}&sub3={variation_id}&sub4={campaign_id}&sub5={format}",
"postback_template": "https://s.magsrv.com/tag.php?goal=[GOAL_ID]&tag={source_click_id}&value={payout}",
"sub_meanings": {
"sub1": "Zone id",
"sub2": "Site id",
"sub3": "Variation id",
"sub4": "Campaign id",
"sub5": "Format"
},
"notes": "Replace [GOAL_ID] with the Conversion Goal ID from your ExoClick account…"
}
]
}- Built-in presets: exoclick, trafficstars, propellerads, adsterra, richads. `[BRACKETED]` pieces in a postback_template are account-specific values you fill in after copying it into your own traffic source.
- tracking_template is the query string to append to a zone /serve (or /track/click) URL — your parameter names on the left, the network’s own macros on the right; the network expands them before the request reaches Affset.
GET/api/traffic-sourcesList traffic sources
Query parameters
| Name | Type | Description |
|---|---|---|
| status | enum | active | archived. |
| limit | integer | 1–100, default 50. |
| offset | integer | Default 0. |
| sort | enum | name | created_at (default). |
| order | enum | asc | desc (default). |
curl "https://api.affset.com/api/traffic-sources?status=active" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"traffic_sources": [
{
"id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
"name": "ExoClick — main",
"preset": "exoclick",
"tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}&sub2={site_id}&sub3={variation_id}&sub4={campaign_id}&sub5={format}",
"postback_template": "https://s.magsrv.com/tag.php?goal=abc123&tag={source_click_id}&value={payout}",
"has_api_token": false,
"status": "active",
"created_at": 1755640000000,
"updated_at": 1755640000000
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"has_more": false
}
}POST/api/traffic-sourcesCreate a traffic source
Body
| Name | Type | Description |
|---|---|---|
| name * | string | Unique within the tenant. Max 200 characters. |
| preset | enum | Copying a preset (exoclick | trafficstars | propellerads | adsterra | richads) fills tracking_template / postback_template unless you also send your own; the row stays fully editable and remembers its preset. |
| tracking_template | string | Raw query string appended after the zone URL’s "?" — must not start with "?"/"&" and no control characters. Max 2000 characters. Network macro syntaxes ({x}, ${X}, ##X##, [X]) pass through byte-exact. |
| postback_template | string | The network’s S2S conversion endpoint, using the same macros as a zone postback_url ({payout}, {source_click_id}, {sub1}…{sub5}). Must be http(s) if present. Max 2000 characters. |
| api_token | string | The network’s API credential, stored write-only; powers cost sync for the exoclick and trafficstars presets. Max 500 characters, no control characters. |
| status | "active" | "archived" | Default active. |
curl -X POST "https://api.affset.com/api/traffic-sources" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"name":"ExoClick — main","preset":"exoclick","api_token":"your-exoclick-api-token"}'{
"id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
"status": "active",
"created_at": 1755640000000
}- 409 if the name already exists in the namespace.
GET/api/traffic-sources/{traffic_source_id}Get one traffic source
curl "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
"name": "ExoClick — main",
"preset": "exoclick",
"tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}",
"postback_template": "https://s.magsrv.com/tag.php?goal=abc123&tag={source_click_id}&value={payout}",
"has_api_token": false,
"status": "active",
"linked_zones": 3,
"created_at": 1755640000000,
"updated_at": 1755640000000
}- Same shape as List, plus linked_zones — how many zones currently reference it.
PUT/api/traffic-sources/{traffic_source_id}Update a traffic source (partial)
Body
| Name | Type | Description |
|---|---|---|
| name | string | New name; must stay unique within the tenant. |
| preset | enum | null | exoclick | trafficstars | propellerads | adsterra | richads. null or "" clears it (without touching the templates already on the row). |
| tracking_template | string | Replaces the stored template; send "" to clear it. |
| postback_template | string | Replaces the stored template; send "" to clear it. |
| api_token | string | null | Omit to leave unchanged, null or "" to clear, or a string to replace it. |
| status | "active" | "archived" | New status. |
curl -X PUT "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"api_token":null,"status":"active"}'{
"id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
"updated_at": 1755650000000
}- At least one field is required.
DELETE/api/traffic-sources/{traffic_source_id}Delete a traffic source
curl -X DELETE "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"- 409 with linked_zones while any zone still references it — unlink those zones or set status to archived instead (keeps history attributable).
POST/api/traffic-sources/{traffic_source_id}/sync-costsPull spend from the network now
Body
| Name | Type | Description |
|---|---|---|
| date_from | string | Inclusive YYYY-MM-DD start of the window to sync. Optional — omit both dates to sync the hourly job’s own window (today plus the two previous UTC days). |
| date_to | string | Inclusive YYYY-MM-DD end. Not in the future; at most 31 days after date_from. Send an explicit range once after adding a token to backfill history. |
curl -X POST "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22/sync-costs" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"date_from":"2026-08-01","date_to":"2026-08-28"}'{
"synced": true,
"date_from": "2026-08-01",
"date_to": "2026-08-28",
"days": 28,
"rows_synced": 54,
"rows_skipped": 0,
"cost_total": 148.52
}- Each synced day is replaced wholesale with what the network reports, so revised numbers never double count. rows_skipped counts malformed or out-of-window rows the network returned (normally 0).
- 422 when the source has no api_token or its preset has no cost adapter (supported: exoclick, trafficstars). 502 with the network’s failure reason when the network refuses — stored costs are left untouched.
POST/api/traffic-sources/{traffic_source_id}/check-credentialsVerify the stored API token against the network
curl -X POST "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22/check-credentials" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"ok": true,
"error": null
}- One authentication round-trip with the stored api_token — nothing is stored, nothing is revealed. ok: false carries the network’s rejection reason; 422 for presets without a cost adapter.
Offers
The CPA-network catalog. Owner/manager and advertiser-side roles (advertiser, advertiser_manager) manage offers — same ownership rule as Campaigns above. Publisher-side roles (publisher, publisher_manager) get a stripped, payout-only catalog instead: active public/apply offers, plus any private offer they already hold a link for.
GET/api/offersList offers
Query parameters
| Name | Type | Description |
|---|---|---|
| status | enum | active | paused | archived. |
| limit | integer | 1–100, default 50. |
| offset | integer | Default 0. |
curl "https://api.affset.com/api/offers?status=active" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"offers": [
{
"id": 12,
"name": "Sweeps SOI — US",
"description": null,
"category": null,
"status": "active",
"destination_url": "https://landing.example/sweeps?clid={click_id}",
"preview_url": null,
"visibility": "public",
"hold_days": 7,
"auto_approve": true,
"daily_conversion_cap": null,
"monthly_payout_cap_cents": null,
"per_affiliate_daily_cap": null,
"allowed_traffic": [
"push",
"pop"
],
"terms": null,
"campaign_id": 345,
"user_email": "advertiser@yournetwork.com",
"created_at": 1755640000000,
"updated_at": 1755640000000,
"goals": [
{
"id": 30,
"name": "Registration",
"conversion_type": "reg",
"payout_cents": 250,
"revenue_cents": 400,
"sort": 0
},
{
"id": 31,
"name": "First deposit",
"conversion_type": "dep",
"payout_cents": 2500,
"revenue_cents": 4000,
"sort": 1
}
]
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"has_more": false
}
}- Publisher-side entries carry the stripped shape instead: no destination_url, revenue_cents, caps, campaign_id or user_email — plus link_issued (whether the caller already holds a tracking link) and application_status (the caller’s own latest application, or null).
- Only active offers that are public or apply, or a private offer the caller already holds a link for, are listed for publisher-side roles.
POST/api/offersCreate an offer
Body
| Name | Type | Description |
|---|---|---|
| name * | string | Offer name. |
| destination_url * | string | Must be http(s). Accepts the same macros as a campaign redirect_url, e.g. {click_id} — see Ad serving. |
| status | "active" | "paused" | "archived" | Default paused. |
| visibility | "public" | "apply" | "private" | public lets any publisher self-issue a link; apply requires an approved application first; private is granted only by an operator or the offer’s advertiser. Default public. |
| goals * | array | 1–20 entries: { name, conversion_type, payout_cents, revenue_cents, sort? }. conversion_type ([A-Za-z0-9_.-]{1,64}) must equal the conversion pixel’s type= parameter and be unique within the offer. revenue_cents (what the advertiser pays) minus payout_cents (what the affiliate earns) is the network margin. |
| user_email | string | Whose advertiser this belongs to — required for owner, manager and advertiser_manager, same ownership rule as Campaigns. Advertisers may omit it (defaults to themselves). The materialized campaign inherits the same user. |
| description | string | Optional. |
| category | string | Optional. |
| preview_url | string | Optional. Must be http(s) if present. |
| terms | string | Optional. |
| allowed_traffic | string[] | Optional free-form traffic type tags, e.g. ["push", "pop"]. |
| hold_days | integer | 0–90 — how long a conversion sits in review before it’s eligible to pay out. Default 0. |
| auto_approve | boolean | Skip manual review and clear the hold automatically once hold_days elapses. Default true. |
| daily_conversion_cap | integer | Max conversions/day across the offer — later ones land rejected with status_reason cap_exceeded. Optional, no cap by default. |
| monthly_payout_cap_cents | integer | Max total payout cents/month across the offer. Optional. |
| per_affiliate_daily_cap | integer | Max conversions/day for a single affiliate. Optional. |
curl -X POST "https://api.affset.com/api/offers" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"name":"Sweeps SOI — US","destination_url":"https://landing.example/sweeps?clid={click_id}","status":"active","visibility":"public","hold_days":7,"user_email":"advertiser@yournetwork.com","goals":[{"name":"Registration","conversion_type":"reg","payout_cents":250,"revenue_cents":400},{"name":"First deposit","conversion_type":"dep","payout_cents":2500,"revenue_cents":4000}]}'{
"id": 12,
"campaign_id": 345,
"status": "active",
"created_at": 1755640000000
}- Each offer materializes to exactly one serving campaign (campaign_id) — serving, targeting, budgets and payout rules are the existing campaign machinery underneath it. Offer-owned campaign fields (name, destination_url, status) must be managed through this endpoint, not through Campaigns directly.
- Activating an offer (status: active) consumes the tenant’s active-campaign plan allowance and can return 402.
- On a conversion, the pixel’s type is matched against the offer’s goals: a match writes spend = revenue_cents/100, payout = payout_cents/100 (a zone-scoped payout rule still overrides the payout for that affiliate); a non-matching or missing type records the conversion at $0/$0 with postback_skipped: non_goal_type.
GET/api/offers/{offer_id}Get one offer
curl "https://api.affset.com/api/offers/12" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"id": 12,
"name": "Sweeps SOI — US",
"description": null,
"category": null,
"status": "active",
"destination_url": "https://landing.example/sweeps?clid={click_id}",
"preview_url": null,
"visibility": "public",
"hold_days": 7,
"auto_approve": true,
"daily_conversion_cap": null,
"monthly_payout_cap_cents": null,
"per_affiliate_daily_cap": null,
"allowed_traffic": [
"push",
"pop"
],
"terms": null,
"campaign_id": 345,
"user_email": "advertiser@yournetwork.com",
"created_at": 1755640000000,
"updated_at": 1755640000000,
"goals": [
{
"id": 30,
"name": "Registration",
"conversion_type": "reg",
"payout_cents": 250,
"revenue_cents": 400,
"sort": 0
},
{
"id": 31,
"name": "First deposit",
"conversion_type": "dep",
"payout_cents": 2500,
"revenue_cents": 4000,
"sort": 1
}
]
}- Role-shaped as in List. A publisher holding a link for this offer also gets tracking_link and zone_id in the response.
PUT/api/offers/{offer_id}Update an offer (partial)
Body
| Name | Type | Description |
|---|---|---|
| name | string | Propagates to the materialized campaign. |
| destination_url | string | Propagates to the materialized campaign’s redirect_url. |
| status | "active" | "paused" | "archived" | Propagates to the materialized campaign: active serves, paused/archived pause it. |
| visibility | "public" | "apply" | "private" | See Create an offer. |
| goals | array | Replaces the whole list — same shape as Create (name, conversion_type, payout_cents, revenue_cents, sort?). Send every goal you want to keep; ids are ignored and new ids are assigned. |
| user_email | string | Reassign the owning advertiser; same rule as Create. |
| description | string | null | Send null or "" to clear. |
| category | string | null | Send null or "" to clear. |
| preview_url | string | null | Must be http(s) if present. Send null or "" to clear. |
| terms | string | null | Send null or "" to clear. |
| allowed_traffic | string[] | null | Send null or [] to clear. |
| hold_days | integer | 0–90. |
| auto_approve | boolean | Optional. |
| daily_conversion_cap | integer | null | Send null to clear. |
| monthly_payout_cap_cents | integer | null | Send null to clear. |
| per_affiliate_daily_cap | integer | null | Send null to clear. |
curl -X PUT "https://api.affset.com/api/offers/12" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"status":"paused"}'{
"id": 12,
"updated_at": 1755650000000
}- Only the fields you send are changed — any subset of the create fields.
- Setting status to active runs the tenant’s active-campaign plan check and can return 402.
DELETE/api/offers/{offer_id}Delete an offer
curl -X DELETE "https://api.affset.com/api/offers/12" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"- 409 with linked_zones while any publisher still holds a link — archive the offer (status: archived) instead if you want to keep their history attributable.
- Deleting detaches and pauses the materialized campaign but keeps it and its event history.
POST/api/offers/{offer_id}/linkIssue a tracking link
Body
| Name | Type | Description |
|---|---|---|
| user_email | string | The publisher to issue for. Required for owner, manager, the offer’s advertiser, and publisher_manager (naming their managed publisher). A publisher self-issuing on a public offer omits it. |
curl -X POST "https://api.affset.com/api/offers/12/link" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{}'{
"offer_id": 12,
"zone_id": "8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
"tracking_link": "https://api.affset.com/track/click/345/8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
"created": true
}- A publisher self-issues on an active public offer; apply/private offers return 403 with code apply_required for a publisher acting alone. Operators (and the offer’s advertiser) can issue for a named publisher via user_email on any visibility — including apply/private, which then becomes visible to that publisher — and this also approves a pending application for them if one exists.
- Issuance creates a dedicated zone per (offer, publisher) pair, named "«offer» — «publisher»" — repeat calls return the same link and reactivate the zone if it had gone inactive. Creating the first link for a new (offer, publisher) pair counts against the zone plan allowance and can return 402; returning or reactivating an existing one does not.
- The dedicated zone is an ordinary zone afterward — set its postback_url, traffic_back_url, or a zone-scoped payout rule same as any other zone.
- The link host honors the tenant’s custom_api_domain.
POST/api/offers/{offer_id}/applyApply for an offer
Access: Publisher-side roles only. A publisher applies for itself; publisher_manager must name a managed publisher via user_email.
Body
| Name | Type | Description |
|---|---|---|
| message | string | Optional, max 1000 characters. |
| user_email | string | Required for publisher_manager — the managed publisher applying. |
curl -X POST "https://api.affset.com/api/offers/12/apply" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"message":"Push traffic, US, 5k/day"}'{
"id": 81,
"offer_id": 12,
"user_email": "publisher@example.com",
"status": "pending",
"created_at": 1787356800000
}- Only offers with visibility: "apply" accept applications. A public offer returns 409 with code public_offer — issue a link directly instead. A private or inactive offer reads as 404, same as any offer the caller can’t see.
- Only one pending application may exist per offer and publisher — re-applying after a rejection or revocation creates a new history row.
GET/api/offer-applicationsList offer applications
Access: Owner/manager see the tenant queue. advertiser-side roles see applications for offers they own or manage. publisher sees their own; publisher_manager sees their managed publishers’.
Query parameters
| Name | Type | Description |
|---|---|---|
| status | enum | pending | approved | rejected | revoked. |
| offer_id | integer | Restrict to one offer. |
| limit | integer | 1–100, default 50. |
| offset | integer | Default 0. |
curl "https://api.affset.com/api/offer-applications?status=pending&offer_id=12" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"applications": [
{
"id": 81,
"offer_id": 12,
"offer_name": "Sweeps SOI — US",
"user_email": "publisher@example.com",
"status": "pending",
"message": "Push traffic, US, 5k/day",
"reviewed_by": null,
"reviewed_at": null,
"created_at": 1787356800000
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"has_more": false
}
}- Publisher-facing responses omit reviewed_by — they see the outcome and when, never who decided it.
PATCH/api/offer-applications/{application_id}Approve, reject or revoke an application
Access: Owner, manager, and advertiser-side roles that own or manage the offer it targets. Publisher-side roles get 403.
Body
| Name | Type | Description |
|---|---|---|
| status * | "approved" | "rejected" | "revoked" | Allowed transitions: pending → approved | rejected, and approved → revoked. |
curl -X PATCH "https://api.affset.com/api/offer-applications/81" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"status":"approved"}'{
"id": 81,
"offer_id": 12,
"user_email": "publisher@example.com",
"status": "approved",
"zone_id": "8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
"tracking_link": "https://api.affset.com/track/click/345/8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
"reviewed_at": 1787360000000
}- Approval atomically creates or reactivates the dedicated zone and returns zone_id/tracking_link; creating a new zone counts against the plan’s zone limit and can return 402.
- Revocation atomically pauses that zone without deleting stats history. Re-applying afterward creates a new application history row.
- 409 when the offer is no longer active/apply, or the application isn’t in the expected starting status for that transition (pending for approved/rejected, approved for revoked).
Payouts
Money out for the network: publisher balance, ledger history, and payout requests. Publishers act only for themselves; publisher_manager acts for a managed publisher (user_email required); owner/manager act for any publisher (user_email required) and are the only roles that can decide requests or post manual adjustments. Advertiser-side roles get 403 on this entire tree — affiliate earnings are the network’s ledger, never the advertiser’s. Amounts are integer cents throughout.
GET/api/payouts/balanceGet a publisher’s balance
Query parameters
| Name | Type | Description |
|---|---|---|
| user_email | string | Required for owner, manager and publisher_manager — the publisher to look up. A publisher omits it (defaults to themselves). |
curl "https://api.affset.com/api/payouts/balance?user_email=publisher%40example.com" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"user_email": "publisher@example.com",
"balance_cents": 128500,
"locked_cents": 5000,
"available_cents": 123500,
"payout_min_cents": 5000
}- locked_cents is the total of that publisher’s currently open (requested or approved) payout requests; available_cents is what a new request can draw against.
GET/api/payouts/balancesList every publisher’s balance
Access: Owner and manager only.
Query parameters
| Name | Type | Description |
|---|---|---|
| limit | integer | 1–100, default 50. |
| offset | integer | Default 0. |
curl "https://api.affset.com/api/payouts/balances" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"balances": [
{
"user_email": "publisher@example.com",
"balance_cents": 128500,
"locked_cents": 5000,
"available_cents": 123500
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"has_more": false
}
}- A paginated overview across every publisher with ledger activity — not the full user list.
GET/api/payouts/ledgerGet a publisher’s ledger
Query parameters
| Name | Type | Description |
|---|---|---|
| user_email | string | Required for owner, manager and publisher_manager — the publisher to read. A publisher omits it (defaults to themselves). |
| limit | integer | 1–100, default 50. |
| offset | integer | Default 0. |
curl "https://api.affset.com/api/payouts/ledger" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"user_email": "publisher@example.com",
"entries": [
{
"id": 501,
"amount_cents": 3000,
"kind": "conversion_credit",
"conversion_id": "7291834650192837",
"payout_request_id": null,
"settled": false,
"reversed": false,
"note": null,
"created_by": null,
"created_at": 1787356800000
},
{
"id": 502,
"amount_cents": -2500,
"kind": "adjustment",
"conversion_id": null,
"payout_request_id": null,
"settled": false,
"reversed": false,
"note": "chargeback on conversion 991",
"created_by": "owner@acme-media.com",
"created_at": 1787360000000
}
],
"pagination": {
"total": 2,
"limit": 50,
"offset": 0,
"has_more": false
}
}- kind is conversion_credit | conversion_reversal | payout | adjustment. settled means the entry has been folded into a paid payout request; reversed means a later reversal cancelled it — the entry itself stays for the audit trail either way.
- Publisher-facing responses omit created_by.
POST/api/payouts/adjustmentsPost a manual ledger adjustment
Access: Owner and manager only.
Body
| Name | Type | Description |
|---|---|---|
| user_email * | string | The publisher whose balance this adjusts. |
| amount_cents * | integer | Non-zero, ±$1,000,000 cap. Negative debits the balance, positive credits it. |
| note * | string | Required — an adjustment must say why. |
curl -X POST "https://api.affset.com/api/payouts/adjustments" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"user_email":"publisher@example.com","amount_cents":-2500,"note":"chargeback on conversion 991"}'{
"id": 502,
"user_email": "publisher@example.com",
"amount_cents": -2500,
"kind": "adjustment",
"created_at": 1787360000000,
"balance_cents": 126000,
"locked_cents": 5000,
"available_cents": 121000,
"payout_min_cents": 5000
}- Takes effect immediately — the response includes the publisher’s new balance.
GET/api/payout-requestsList payout requests
Query parameters
| Name | Type | Description |
|---|---|---|
| status | enum | requested | approved | paid | rejected. |
| user_email | string | Narrow to one publisher’s requests. Always scoped to what the caller can already see — owner/manager may use any publisher, publisher_manager only a managed one. |
| format | "csv" | Owner/manager only. Returns a CSV export instead of JSON, capped at 10,000 rows — narrow status/user_email above that. |
| limit | integer | 1–100, default 50. Ignored when format=csv. |
| offset | integer | Default 0. Ignored when format=csv. |
curl "https://api.affset.com/api/payout-requests?status=requested" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"payout_requests": [
{
"id": 91,
"user_email": "publisher@example.com",
"amount_cents": 50000,
"status": "requested",
"method": "USDT TRC-20 T…",
"note": "monthly cashout",
"created_by": "publisher@example.com",
"decided_by": null,
"decided_at": null,
"paid_at": null,
"created_at": 1787356800000
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"has_more": false
}
}- Publishers and publisher_manager see only their own (or managed) requests.
- 422 (CSV only) if the filtered result exceeds 10,000 rows — narrow the filter and retry.
POST/api/payout-requestsCreate a payout request
Access: publisher (for themselves), publisher_manager (user_email required — a managed publisher), owner and manager (user_email required — any publisher).
Body
| Name | Type | Description |
|---|---|---|
| amount_cents * | integer | Positive, up to $1,000,000. |
| method * | string | Where the payout should go, e.g. "USDT TRC-20 T…". |
| note | string | Optional. |
| user_email | string | Required for owner, manager and publisher_manager — the publisher this request is for. A publisher omits it (defaults to themselves). |
curl -X POST "https://api.affset.com/api/payout-requests" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"amount_cents":50000,"method":"USDT TRC-20 T…","note":"monthly cashout"}'{
"id": 91,
"user_email": "publisher@example.com",
"amount_cents": 50000,
"status": "requested",
"method": "USDT TRC-20 T…",
"note": "monthly cashout",
"decided_at": null,
"paid_at": null,
"created_at": 1787356800000
}- A publisher-side request must clear the tenant’s payout_min_cents (default 5000, see Tenant settings) — an operator opening one on a publisher’s behalf may close out any balance regardless of the minimum.
- Only one open (requested or approved) request may exist per publisher — a second attempt returns 409 with code open_request_exists. An amount exceeding the publisher’s available balance returns 409 with code insufficient_balance.
- The 201 response omits created_by/decided_by for publisher-side callers.
PATCH/api/payout-requests/{payout_request_id}Decide a payout request
Access: Owner and manager only.
Body
| Name | Type | Description |
|---|---|---|
| status * | "approved" | "rejected" | "paid" | Allowed transitions: requested → approved | rejected, and approved → paid | rejected. |
| note | string | Optional. |
curl -X PATCH "https://api.affset.com/api/payout-requests/91" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"status":"paid","note":"sent 2026-08-24"}'{
"id": 91,
"user_email": "publisher@example.com",
"amount_cents": 50000,
"status": "paid",
"method": "USDT TRC-20 T…",
"note": "sent 2026-08-24",
"created_by": "publisher@example.com",
"decided_by": "owner@acme-media.com",
"decided_at": 1787360000000,
"paid_at": 1787360000000,
"created_at": 1787356800000
}- Marking a request paid re-checks that the publisher’s balance still covers it — 409 with code insufficient_balance if not (e.g. a chargeback landed since approval) — and settles the corresponding ledger entries.
- 404 for an unknown id; 409 when the request isn’t in the expected starting status (requested for approved, requested or approved for rejected, approved for paid).
POST/api/payout-requests/bulkBulk-decide payout requests
Access: Owner and manager only.
Body
| Name | Type | Description |
|---|---|---|
| ids * | array | 1–100 positive integer payout request ids. |
| status * | "approved" | "rejected" | "paid" | Same statuses as Decide a payout request, applied to every id. |
| note | string | Optional; applied to every id decided. |
curl -X POST "https://api.affset.com/api/payout-requests/bulk" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '{"ids":[91,92,93],"status":"approved"}'{
"status": "approved",
"applied": 2,
"results": [
{
"id": 91,
"ok": true,
"user_email": "publisher@example.com",
"amount_cents": 50000,
"status": "approved",
"decided_by": "owner@acme-media.com",
"decided_at": 1787360000000
},
{
"id": 92,
"ok": false,
"error": "Cannot transition a 'paid' request to 'approved'"
}
]
}- Each id is decided independently — one bad row (already-decided, insufficient balance) never blocks the rest of the batch. applied counts only the ones that actually changed.
- Duplicate ids in the same call are decided once.
Targeting
Same campaign-scoping as Campaigns above. Enforced on /serve only — never on a direct /track/click link.
GET/api/targeting-rule-typesCatalog of targeting rule types
curl "https://api.affset.com/api/targeting-rule-types" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"targeting_rule_types": [
{
"id": 1,
"name": "geo",
"description": "Comma-separated ISO-3166 alpha-2 country codes, matched against the request’s country."
},
{
"id": 2,
"name": "device_type",
"description": "desktop, mobile or tablet, comma-separated."
},
{
"id": 3,
"name": "capping",
"description": "Stored only — not evaluated by /serve."
},
{
"id": 4,
"name": "zone_id",
"description": "Comma-separated zone IDs. This is how zone blacklists/whitelists work."
},
{
"id": 5,
"name": "os",
"description": "Comma-separated OS names, exact match, e.g. Android, iOS, Windows."
},
{
"id": 6,
"name": "browser",
"description": "Comma-separated browser names, exact match, e.g. Chrome, Safari."
},
{
"id": 7,
"name": "weekdays",
"description": "Stored only — not evaluated by /serve."
},
{
"id": 8,
"name": "hours",
"description": "Stored only — not evaluated by /serve."
},
{
"id": 9,
"name": "unique_users",
"description": "A single \"visits/hours\" value, e.g. 1/24 — frequency capping."
}
]
}- geo, os and browser are matched exactly and case-sensitively. An unmatched whitelist value silently stops delivery rather than erroring anywhere.
GET/api/campaigns/{campaign_id}/targeting_rulesList a campaign’s targeting rules
curl "https://api.affset.com/api/campaigns/42/targeting_rules" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"targeting_rules": [
{
"id": 501,
"targeting_rule_type_id": 1,
"targeting_method": "whitelist",
"rule": "BR,MX"
}
]
}POST/api/campaigns/{campaign_id}/targeting_rulesReplace a campaign’s targeting rules
Body
| Name | Type | Description |
|---|---|---|
| (array body) * | array | targeting_method is "whitelist" or "blacklist". |
curl -X POST "https://api.affset.com/api/campaigns/42/targeting_rules" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE" \
-H "Content-Type: application/json" \
-d '[{"id":501,"targeting_rule_type_id":1,"targeting_method":"whitelist","rule":"BR,MX"},{"targeting_rule_type_id":4,"targeting_method":"blacklist","rule":"550e8400-e29b-41d4-a716-446655440000"}]'[
{
"id": 501,
"targeting_rule_type_id": 1,
"targeting_method": "whitelist",
"rule": "BR,MX"
},
{
"id": 502,
"targeting_rule_type_id": 4,
"targeting_method": "blacklist",
"rule": "550e8400-e29b-41d4-a716-446655440000"
}
]Recipe: upsert one rule
- GET the campaign's current targeting_rules.
- Find a rule with the same type + method (or decide there isn't one).
- Replace its rule value, or add a new entry with no id.
- POST the whole array back.
Recipe: blacklist zones
- GET the targeting-type catalog to find zone_id's id.
- GET the campaign's rules; find the zone_id + blacklist rule (or start one).
- Merge your zone IDs into its comma-separated value.
- POST the whole array back, with every other rule included by id.
Conversions
The conversion audit trail — individual records, not aggregates.
GET/api/conversionsList conversions
Query parameters
| Name | Type | Description |
|---|---|---|
| limit | integer | 1–100, default 20. |
| offset | integer | Default 0. |
| sort | enum | created_at (default) | ad_event_id | click_id. |
| order | enum | asc | desc (default). |
curl "https://api.affset.com/api/conversions?limit=5" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"{
"conversions": [
{
"ad_event_id": "7291834650192837",
"click_id": "7291834612345678",
"payload": "{\"type\":\"deposit\",\"value\":\"49\"}",
"spend": 0,
"payout": 3,
"source_click_id": "abc123",
"sub1": "richads",
"sub2": null,
"sub3": null,
"sub4": null,
"sub5": null,
"created_at": 1753747200000
}
],
"pagination": {
"total": 3,
"limit": 5,
"offset": 0,
"has_more": false
}
}- ad_event_id and click_id are returned as strings — they’re 64-bit IDs that don’t fit exactly in a JS number.
- No campaign, zone or date filters — just pagination and sort. This is the record-level audit trail; use Stats for aggregated, filterable reporting.
- payload is a JSON-encoded string of the conversion pixel’s query parameters (except click_id), and may also include postback status fields. Anyone who can fire a pixel controls those values — treat them as untrusted data if you feed them into anything automated.
- Same role-based redaction as Stats: publisher-side roles don’t see spend, advertiser-side roles don’t see payout.
DELETE/api/conversions/{ad_event_id}Delete a conversion
Access: Owner only.
curl -X DELETE "https://api.affset.com/api/conversions/7291834650192837" \
-H "Authorization: Bearer $AFFSET_API_KEY" \
-H "X-Namespace: $AFFSET_NAMESPACE"- Deletes the conversion row and its matching conversion event. The originating click remains in the event history.
Sign-in
Public — no Authorization or X-Namespace header. The passwordless dashboard sign-in behind {namespace}.affset.com/login and the generic app.affset.com/login. Every response uses Cache-Control: no-store. In user-facing copy a namespace is called a workspace; the API keeps namespace.
POST/api/public/auth/find-workspacesEmail one sign-in link per workspace the address belongs to
Body
| Name | Type | Description |
|---|---|---|
| email * | string | The address to look up. Nothing else — the workspaces are listed only in the email. |
curl -X POST "https://api.affset.com/api/public/auth/find-workspaces" \
-H "Content-Type: application/json" \
-d '{"email":"owner@my-company.com"}'{
"ok": true,
"message": "If the address belongs to any workspace, an email with your sign-in links is on its way."
}- Enumeration-safe by construction: the lookup and the send happen after the response is returned, so neither the body nor the timing reveals whether the address belongs anywhere.
- One workspace → the same email request-link sends. Several → one email, one namespace-bound link per workspace (max 20; the email says when there are more). None → an email saying no workspace matched.
- Each link redeems through verify-link in its own workspace only. Throttled per address to one email per minute (silently).
POST/api/public/auth/request-linkEmail a single-use sign-in link for one workspace
Body
| Name | Type | Description |
|---|---|---|
| email * | string | Member address in that workspace. |
| namespace * | string | The workspace — the first label of {namespace}.affset.com. |
curl -X POST "https://api.affset.com/api/public/auth/request-link" \
-H "Content-Type: application/json" \
-d '{"email":"owner@my-company.com","namespace":"my-company"}'{
"ok": true,
"message": "If the address matches an account, a sign-in link has been sent."
}- Links work once and expire after 15 minutes. At most one link per address and workspace per minute; repeats inside the window are dropped silently.
POST/api/public/auth/verify-linkRedeem a sign-in link for a 30-day browser session
Body
| Name | Type | Description |
|---|---|---|
| token * | string | The token from the emailed link’s ?token= parameter. |
curl -X POST "https://api.affset.com/api/public/auth/verify-link" \
-H "Content-Type: application/json" \
-d '{"token":"token-from-the-login-link"}'{
"token": "browser-session-token",
"namespace": "my-company",
"expires_at": 1721347200000
}- The session token is used exactly like an API key: Authorization: Bearer <token> plus X-Namespace. It is bound to the workspace the link was issued for.
- Redeeming the tenant owner’s link also marks the owner email verified.
Ad serving
Public — no Authorization or X-Namespace header. Namespace is resolved from the zone/campaign in the URL. On error these return plain text, not JSON.
GET/serve/{zone_id}Entry point you give a traffic source
Query parameters
| Name | Type | Description |
|---|---|---|
| source_click_id | string | Your source’s click id. Legacy alias: sub_id. |
| sub1…sub5 | string | Passed through to the click and any conversions on it. |
| cost | decimal | Media cost for this impression — see the note below. |
curl "https://api.affset.com/serve/550e8400-e29b-41d4-a716-446655440000"- Picks one eligible campaign from the zone’s active, targeting-matched campaigns and redirects to /track/click for it.
- No eligible campaign → redirects to the zone’s traffic_back_url if set, otherwise a plain-text 404.
- source_click_id and sub1–sub5 carry forward to /track/click. cost is recorded on the /serve impression and deliberately not forwarded, so it is counted once; other query parameters are dropped.
- Geo/device/OS/browser targeting is enforced here. It is not enforced on a direct /track/click link.
GET/track/click/{campaign_id}/{zone_id}Direct tracking link for one campaign
Query parameters
| Name | Type | Description |
|---|---|---|
| source_click_id | string | Legacy alias: sub_id. |
| sub1…sub5 | string | Stored on the click and attributed to later conversions. |
| cost | decimal | Only send this here or on /serve for a given stream, never both — sending it to both double-counts. |
curl "https://api.affset.com/track/click/42/550e8400-e29b-41d4-a716-446655440000"- Records the click and, for CPM campaigns, computes spend as rate/1000 — CPA campaigns accrue spend on conversion instead.
- Not geo/targeting gated — only /serve enforces targeting.
- Macros in redirect_url: {click_id}, {zone_id}, {source_click_id} (alias {aff_sub_id}), {sub1}…{sub5}. Values are percent-encoded on substitution. A macro with nothing to fill stays as literal text.
GET/px/{click_id}Conversion pixel (into Affset)
Query parameters
| Name | Type | Description |
|---|---|---|
| click_id | string | Required when not in the path. Affset click id from the offer redirect’s {click_id}. |
| type | string | Goal label. When the campaign has payout_goal_type set, only an exact type= match accrues spend/payout; others still record at $0. |
| sub1…sub5 | string | Fill empty slots from the click only — cannot overwrite values already stored on the click. |
| (other params) | string | Stored in the conversion payload (except click_id). source_click_id here cannot rewrite the click’s token used for the affiliate postback. |
curl "https://api.affset.com/px/7291834612345678?type=deposit"- Send Accept: application/json for a JSON response. Other callers receive the 1×1 GIF used by browser pixels.
- After a non-silent conversion, Affset GETs the zone’s postback_url (affiliate passback) with macros {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Skipped when silent or when the zone has no postback_url.
- Payout comes from campaign payout rules (zone-specific → global → $0), not from a query param on /px.