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.

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

RoleAccess
ownerFull access to everything. The only role that can permanently delete the tenant.
managerSame day-to-day access as owner — campaigns, zones, team, payouts, targeting. Can’t delete the tenant.
advertiserManages their own campaigns and can read zones. Sees campaign spend, but not publisher payout, media cost, or ROI.
advertiser_managerManages campaigns for assigned advertisers and can add advertisers to their own team. Uses the same financial redaction as advertiser.
publisherManages their own zones and has no campaign access. Sees payout, media cost, and ROI, but not advertiser spend.
publisher_managerManages 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

Request
curl "https://api.affset.com/api/me" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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.

StatusMeaning
400Bad Request — missing header, parameter, or body, or an invalid value.
401Unauthorized — invalid or expired key, or X-Namespace doesn't match it.
402Payment Required — you’re at a plan limit. See below.
403Forbidden — your role or permissions don't allow this. Also used to mask ownership on writes.
404Not Found — the resource doesn't exist, or isn't visible to your role.
409Conflict — something with the same identity already exists.
422Unprocessable Entity — the request is well-formed but cannot be processed (e.g. a CSV export that exceeds the row cap).
500Internal 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

Request
curl "https://api.affset.com/api/tenant" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
companystringDisplay name shown in the dashboard and emails.
timezonestringIANA timezone, e.g. "America/New_York". Drives Stats date bucketing and campaign date-only schedules.
primary_colorstringHex color, e.g. "#4F46E5". Must match #RRGGBB.
secondary_colorstringHex color, e.g. "#14161C". Must match #RRGGBB.
custom_api_domainstringDomain 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_labelsobjectPartial 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.
Request
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"}'
200 response
{
  "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

NameTypeDescription
fromepoch msDefault: start of today, UTC.
toepoch msDefault: now.
group_byenumdate (default) | campaign_id | zone_id | country | conversion_type | status | publisher_email | advertiser_email | sub1…sub5.
campaign_idscomma-separatedRestrict to these campaigns.
zone_idscomma-separatedRestrict to these zones.
publisher_manager_emailstringRestrict to zones owned by publishers assigned to this manager. Owner/manager may use any manager email; publisher_manager may use only their own.
advertiser_emailstringNarrow 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_emailstringNarrow 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…sub5stringA value, a comma list, or an empty string to match rows where that sub is unset.
conversion_typestringFilter 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.
statuscomma-separatedConversion 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_onlybooleantrue 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.
Request
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"
200 response
{
  "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

NameTypeDescription
statusenumactive | paused | archived. Omit for all statuses.
limitinteger1–100, default 20.
offsetintegerDefault 0.
sortenumname | created_at (default) | start_date.
orderenumasc | desc (default).
Request
curl "https://api.affset.com/api/campaigns?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

Request
curl "https://api.affset.com/api/campaigns/42" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
name *stringCampaign name.
redirect_url *stringMust be http(s). See Ad serving for the macros it can contain. Either this or redirect_urls is required.
redirect_urlsstring[]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_emailstringRequired for owner, manager and advertiser_manager — whose advertiser this bills to. Advertisers may omit it (defaults to themselves).
payment_model"cpm" | "cpa"Default cpm.
ratenumberDefault 0. Rounded to 2 decimals.
payout_goal_typestring | nullOnly conversions whose pixel type= matches this exactly accrue spend/payout — others record at $0.
silentinteger0 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_budgetnumber | null0–999999999.99999.
pacing"asap" | "even"Default asap.
start_date / end_dateepoch msOptional.
targeting_rulesarrayOptional 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.
Request
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"}]}'
201 response
{
  "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

NameTypeDescription
namestringNew campaign name.
redirect_urlstringNew http(s) destination; supports the macros listed under Ad serving. Replaces the whole rotation set with this one URL.
redirect_urlsstring[]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.
ratenumberNon-negative; rounded to 2 decimals.
payout_goal_typestring | nullSend null or an empty string to clear the goal filter.
silentintegerNon-negative silent-conversion cadence; 0 disables it. A positive value requires the silent_conversions feature flag.
daily_budget / total_budgetnumber | nullSend null to clear a budget.
pacing"asap" | "even"Delivery pacing; even works against the daily budget.
start_date / end_dateepoch ms | nullSend null to clear a boundary.
user_emailstringadvertiser_email works identically — both write the same field.
Request
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"}'
200 response
{
  "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

Request
curl -X DELETE "https://api.affset.com/api/campaigns/42" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
204
  • 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

NameTypeDescription
statusenumactive | inactive.
limitinteger1–100, default 20.
offsetintegerDefault 0.
sortenumname | created_at (default) | site_url.
orderenumasc | desc (default).
Request
curl "https://api.affset.com/api/zones?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

Request
curl "https://api.affset.com/api/zones/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
name *stringZone name.
site_urlstringMust be http(s) if present.
traffic_back_urlstringWhere /serve sends traffic when there’s no eligible campaign. Must be http(s) if present.
postback_urlstringAffiliate passback — Affset GETs this on conversion. Macros: {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Must be http(s) if present.
traffic_source_idstringOptional. Must reference a traffic source in this tenant; otherwise 400. Zone reads then include the joined traffic_source_name — see Traffic Sources.
user_emailstringPublishers can only create for themselves. Owner/manager/publisher_manager may assign any publisher.
Request
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}"}'
201 response
{
  "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

NameTypeDescription
namestringNew zone name.
status"active" | "inactive"Unlike campaigns, there’s no archived state for zones.
site_url / traffic_back_url / postback_urlstring | nullSend null to clear a URL.
traffic_source_idstring | nullLink to a traffic source in this tenant, or send null / "" to unlink.
Request
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}'
200 response
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "updated_at": 1753887600000
}

DELETE/api/zones/{zone_id}Delete a zone

Request
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"
204

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.

Unlike the dashboard’s Team page, this response includes each member’s live bearer token in plaintext. If you’re building a UI, log, or support tool on top of this endpoint, redact token before you display or store it anywhere.

Query parameters

NameTypeDescription
type *"user" | "api-key"user = people (dashboard/API logins). api-key = machine keys with no owning person.
Request
curl "https://api.affset.com/api/api-keys?type=user" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
[
  {
    "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

NameTypeDescription
type *"user" | "api-key"Use user for a person with an email, or api-key for a machine credential.

Body

NameTypeDescription
emailstringRequired when type=user, ignored for api-key.
role *enumowner | manager | publisher | advertiser | advertiser_manager | publisher_manager.
permissionsstring[]Any of read, write. Default ["read","write"].
manager_emailstringOnly valid for type=user when role is publisher or advertiser.
expires_atepoch msOptional future expiration time.
Request
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"}'
201 response
{
  "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

NameTypeDescription
token *stringBearer token to revoke.
action *"revoke"Must be revoke.
Request
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"}'
204
  • 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

NameTypeDescription
token *stringAlready-revoked bearer token to remove.
action *"remove"Must be remove.
Request
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"}'
204
  • 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

NameTypeDescription
token *stringActive bearer token to replace.
action *"rotate"Must be rotate.
Request
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"}'
200 response
{
  "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

NameTypeDescription
token *stringBearer token belonging to the person being reassigned.
manager_emailstring | nullThe publisher_manager/advertiser_manager they report to. null clears it.
Request
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"}'
200 response
{
  "user_id": "usr_8f2a1c",
  "manager_email": "manager@acme-media.com"
}

DELETE/api/api-keysClose the account

Access: Owner only.

Deletes the entire tenant — every campaign, zone, team member and history record. Irreversible. This is account closure, not team management.

Body

NameTypeDescription
token *stringOwner API key or active owner session token.
action *"terminate"Must be terminate.
Request
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"}'
204

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

Request
curl "https://api.affset.com/api/campaigns/42/payout_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
payout *number0.00001–9999.99999.
zone_idstringOmit for the global (fallback) rule.
Request
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"}'
201 response
{
  "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

NameTypeDescription
zone_idstringOmit to delete the global rule.
Request
curl -X DELETE "https://api.affset.com/api/campaigns/42/payout_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
204
Changing a payout: there's no update endpoint. To change one, delete the existing rule and create a new one — that leaves a brief window with no rule for that scope, where any conversion resolves to $0. If you're scripting this, create the replacement immediately after the delete, and be ready to re-create the old value if your create call fails.

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

Request
curl "https://api.affset.com/api/traffic-source-presets" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
statusenumactive | archived.
limitinteger1–100, default 50.
offsetintegerDefault 0.
sortenumname | created_at (default).
orderenumasc | desc (default).
Request
curl "https://api.affset.com/api/traffic-sources?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
name *stringUnique within the tenant. Max 200 characters.
presetenumCopying 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_templatestringRaw 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_templatestringThe 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_tokenstringThe 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.
Request
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"}'
201 response
{
  "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

Request
curl "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
namestringNew name; must stay unique within the tenant.
presetenum | nullexoclick | trafficstars | propellerads | adsterra | richads. null or "" clears it (without touching the templates already on the row).
tracking_templatestringReplaces the stored template; send "" to clear it.
postback_templatestringReplaces the stored template; send "" to clear it.
api_tokenstring | nullOmit to leave unchanged, null or "" to clear, or a string to replace it.
status"active" | "archived"New status.
Request
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"}'
200 response
{
  "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

Request
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"
204
  • 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

NameTypeDescription
date_fromstringInclusive 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_tostringInclusive 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.
Request
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"}'
200 response
{
  "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

Request
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"
200 response
{
  "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

NameTypeDescription
statusenumactive | paused | archived.
limitinteger1–100, default 50.
offsetintegerDefault 0.
Request
curl "https://api.affset.com/api/offers?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
name *stringOffer name.
destination_url *stringMust 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 *array1–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_emailstringWhose 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.
descriptionstringOptional.
categorystringOptional.
preview_urlstringOptional. Must be http(s) if present.
termsstringOptional.
allowed_trafficstring[]Optional free-form traffic type tags, e.g. ["push", "pop"].
hold_daysinteger0–90 — how long a conversion sits in review before it’s eligible to pay out. Default 0.
auto_approvebooleanSkip manual review and clear the hold automatically once hold_days elapses. Default true.
daily_conversion_capintegerMax conversions/day across the offer — later ones land rejected with status_reason cap_exceeded. Optional, no cap by default.
monthly_payout_cap_centsintegerMax total payout cents/month across the offer. Optional.
per_affiliate_daily_capintegerMax conversions/day for a single affiliate. Optional.
Request
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}]}'
201 response
{
  "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

Request
curl "https://api.affset.com/api/offers/12" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
namestringPropagates to the materialized campaign.
destination_urlstringPropagates 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.
goalsarrayReplaces 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_emailstringReassign the owning advertiser; same rule as Create.
descriptionstring | nullSend null or "" to clear.
categorystring | nullSend null or "" to clear.
preview_urlstring | nullMust be http(s) if present. Send null or "" to clear.
termsstring | nullSend null or "" to clear.
allowed_trafficstring[] | nullSend null or [] to clear.
hold_daysinteger0–90.
auto_approvebooleanOptional.
daily_conversion_capinteger | nullSend null to clear.
monthly_payout_cap_centsinteger | nullSend null to clear.
per_affiliate_daily_capinteger | nullSend null to clear.
Request
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"}'
200 response
{
  "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

Request
curl -X DELETE "https://api.affset.com/api/offers/12" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
204
  • 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}/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

NameTypeDescription
messagestringOptional, max 1000 characters.
user_emailstringRequired for publisher_manager — the managed publisher applying.
Request
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"}'
201 response
{
  "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

NameTypeDescription
statusenumpending | approved | rejected | revoked.
offer_idintegerRestrict to one offer.
limitinteger1–100, default 50.
offsetintegerDefault 0.
Request
curl "https://api.affset.com/api/offer-applications?status=pending&offer_id=12" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
status *"approved" | "rejected" | "revoked"Allowed transitions: pending → approved | rejected, and approved → revoked.
Request
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"}'
200 response
{
  "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

NameTypeDescription
user_emailstringRequired for owner, manager and publisher_manager — the publisher to look up. A publisher omits it (defaults to themselves).
Request
curl "https://api.affset.com/api/payouts/balance?user_email=publisher%40example.com" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
limitinteger1–100, default 50.
offsetintegerDefault 0.
Request
curl "https://api.affset.com/api/payouts/balances" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
user_emailstringRequired for owner, manager and publisher_manager — the publisher to read. A publisher omits it (defaults to themselves).
limitinteger1–100, default 50.
offsetintegerDefault 0.
Request
curl "https://api.affset.com/api/payouts/ledger" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
user_email *stringThe publisher whose balance this adjusts.
amount_cents *integerNon-zero, ±$1,000,000 cap. Negative debits the balance, positive credits it.
note *stringRequired — an adjustment must say why.
Request
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"}'
201 response
{
  "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

NameTypeDescription
statusenumrequested | approved | paid | rejected.
user_emailstringNarrow 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.
limitinteger1–100, default 50. Ignored when format=csv.
offsetintegerDefault 0. Ignored when format=csv.
Request
curl "https://api.affset.com/api/payout-requests?status=requested" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

NameTypeDescription
amount_cents *integerPositive, up to $1,000,000.
method *stringWhere the payout should go, e.g. "USDT TRC-20 T…".
notestringOptional.
user_emailstringRequired for owner, manager and publisher_manager — the publisher this request is for. A publisher omits it (defaults to themselves).
Request
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"}'
201 response
{
  "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

NameTypeDescription
status *"approved" | "rejected" | "paid"Allowed transitions: requested → approved | rejected, and approved → paid | rejected.
notestringOptional.
Request
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"}'
200 response
{
  "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

NameTypeDescription
ids *array1–100 positive integer payout request ids.
status *"approved" | "rejected" | "paid"Same statuses as Decide a payout request, applied to every id.
notestringOptional; applied to every id decided.
Request
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"}'
200 response
{
  "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

capping, weekdays and hours are accepted and stored, but /serve never evaluates them — writing them has no effect on delivery. Use unique_users for frequency capping instead.
Request
curl "https://api.affset.com/api/targeting-rule-types" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

Request
curl "https://api.affset.com/api/campaigns/42/targeting_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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

This replaces the whole set for the campaign. Any existing rule whose id is left out of the body gets deleted. Always GET the current list first, then send it back with your change folded in — see the recipes below.

Body

NameTypeDescription
(array body) *arraytargeting_method is "whitelist" or "blacklist".
Request
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"}]'
200 response
[
  {
    "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

  1. GET the campaign's current targeting_rules.
  2. Find a rule with the same type + method (or decide there isn't one).
  3. Replace its rule value, or add a new entry with no id.
  4. POST the whole array back.

Recipe: blacklist zones

  1. GET the targeting-type catalog to find zone_id's id.
  2. GET the campaign's rules; find the zone_id + blacklist rule (or start one).
  3. Merge your zone IDs into its comma-separated value.
  4. 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

NameTypeDescription
limitinteger1–100, default 20.
offsetintegerDefault 0.
sortenumcreated_at (default) | ad_event_id | click_id.
orderenumasc | desc (default).
Request
curl "https://api.affset.com/api/conversions?limit=5" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
200 response
{
  "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.

Request
curl -X DELETE "https://api.affset.com/api/conversions/7291834650192837" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
204
  • 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

NameTypeDescription
email *stringThe address to look up. Nothing else — the workspaces are listed only in the email.
Request
curl -X POST "https://api.affset.com/api/public/auth/find-workspaces" \
  -H "Content-Type: application/json" \
  -d '{"email":"owner@my-company.com"}'
200 response — always the same body — invalid, unknown, 1 or many workspaces
{
  "ok": true,
  "message": "If the address belongs to any workspace, an email with your sign-in links is on its way."
}
429per-IP bucket (5 / 15 min), with Retry-After
  • 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).

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

NameTypeDescription
source_click_idstringYour source’s click id. Legacy alias: sub_id.
sub1…sub5stringPassed through to the click and any conversions on it.
costdecimalMedia cost for this impression — see the note below.
Request
curl "https://api.affset.com/serve/550e8400-e29b-41d4-a716-446655440000"
302or 200 with an HTML redirect, per redirect_method
  • 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

NameTypeDescription
source_click_idstringLegacy alias: sub_id.
sub1…sub5stringStored on the click and attributed to later conversions.
costdecimalOnly send this here or on /serve for a given stream, never both — sending it to both double-counts.
Request
curl "https://api.affset.com/track/click/42/550e8400-e29b-41d4-a716-446655440000"
302to the campaign’s redirect_url, macros expanded
  • 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

NameTypeDescription
click_idstringRequired when not in the path. Affset click id from the offer redirect’s {click_id}.
typestringGoal label. When the campaign has payout_goal_type set, only an exact type= match accrues spend/payout; others still record at $0.
sub1…sub5stringFill empty slots from the click only — cannot overwrite values already stored on the click.
(other params)stringStored in the conversion payload (except click_id). source_click_id here cannot rewrite the click’s token used for the affiliate postback.
Request
curl "https://api.affset.com/px/7291834612345678?type=deposit"
2001×1 GIF by default, or {"status":"ok"} for a JSON-flavored request
  • 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.
cost= parsing: must be a plain unsigned decimal, like 14.2 — no currency symbols, no scientific notation. Anything else is silently treated as 0, not rejected, so a broken macro substitution won't error, it'll just quietly record no cost. Max 9999.99999, and only one of /serve or /track/click should ever receive cost= for a given stream — sending it to both double-counts.