API reference
Base: https://app.durum.ai/api/v1/ Authentication: Authorization: Bearer durum_sk_live_… header (see Authentication).
Conventions
- Reads:
GETmethod, parameters in the query string.fromandtoareYYYY-MM-DDdates, optional unless stated otherwise. Without a period, the route applies its own default window. - Writes:
PUTorPOSTdepending on the route, JSON body withContent-Type: application/json. Every write requires awrite:*scope. - Response:
200with{ "data": … }. Every error returns{ "error": "…" }with the matching HTTP code. Responses carryCache-Control: private, no-store. - Your data only: the account is derived from the key. No parameter lets you point at another one.
- Stripped fields: see Fields never returned.
Self-documenting index
GET https://app.durum.ai/api/v1 (with or without a trailing slash) returns, without authentication, the list of read and write routes with their method, scope and full path (/api/v1/dashboard/kpis), plus base_url and a link to this page.
GET https://app.durum.ai/api/v1/tools (key required) returns a JSON manifest (tools[] with name, method, path, scope, inputSchema) limited to the routes covered by your key's scopes. It does not list every route (the calls and creatives routes are missing): the index above remains the reference list.
Dashboard
| Route | Scope | Parameters |
|---|---|---|
GET /dashboard/kpis | read:dashboard | from, to |
GET /dashboard/daily | read:dashboard | from, to |
GET /dashboard/breakdowns | read:dashboard | from, to, dim (default source; values source, campaign, product, rep) |
Responses:
kpis: an object of KPIs aggregated over the period (includingleads,applications,bookings,meetings,shows,no_shows,total_sales,revenue,ticket_moyen).daily: an array, one row per day:report_date,leads,applications,bookings,meetings,no_shows,sales,revenue,spend.breakdowns: an array, one row per dimension value:dimension_key,leads,applications,bookings,shows,no_shows,sales,revenue.
curl "https://app.durum.ai/api/v1/dashboard/breakdowns?from=2026-08-01&to=2026-08-31&dim=campaign" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Ads
| Route | Scope | Parameters |
|---|---|---|
GET /ads/spend | read:ads | from, to |
GET /ads/campaigns | read:ads | from, to |
GET /ads/rules | read:ads | none |
GET /ads/rules/runs | read:ads | rule_id, limit (integer, default 30, max 100) |
Responses:
spend: an array, one row per ad and platform:ad_id,ad_name,campaign_name,campaign_id,adset_name,adset_id,platform,total_spend,total_clicks,total_impressions,total_reach.campaigns: an array, one row per campaign and platform, sorted by descending spend:campaign_id,campaign_name,platform,total_spend,total_clicks,total_impressions,total_reach,nb_ads,nb_adsets.rules: your automated ad rules (label, type, campaign, enabled state, dry-run mode, parameters, last run).rules/runs: the latest runs of one rule.
curl "https://app.durum.ai/api/v1/ads/rules/runs?rule_id=RULE_ID&limit=10" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Attribution
| Route | Scope | Parameters |
|---|---|---|
GET /attribution | read:attribution | from, to |
GET /attribution/health | read:attribution | none |
GET /attribution/report | read:attribution | from, to |
Responses:
attribution: an array, one row per ad. Columns are prefixedout_: identity (out_ad_id,out_ad_name,out_campaign_id,out_campaign_name,out_adset_id,out_adset_name,out_platform), media (out_total_spend,out_total_clicks,out_total_impressions,out_total_reach), results (out_leads,out_applications,out_bookings,out_meetings,out_meetings_present,out_meetings_no_show,out_sales,out_revenue,out_show_rate,out_close_rate,out_rep_names), split by model (out_sales_ftandout_revenue_ftfirst touch,out_sales_ltandout_revenue_ltlast touch,out_sales_assistedandout_revenue_assisted), adjustments (out_refund_amount,out_dispute_amount,out_net_revenue) andout_is_excluded.health: a capture-health object:verdict,events_pub_30j,pct_exact_id,pct_par_nom,pct_non_resolu,pixel_sessions_30j,pixel_derniere_activite,pixel_pct_avec_id,issues.report: an array per source, campaign and ad:out_source,out_campaign,out_ad_id,out_ad_name,out_spend,out_gross_revenue,out_refund_amount,out_dispute_amount,out_net_revenue,out_roi_gross,out_roi_net,out_touchpoints.
curl "https://app.durum.ai/api/v1/attribution?from=2026-08-01&to=2026-08-31" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Events
| Route | Scope | Parameters |
|---|---|---|
GET /events | read:events | type, from, to, limit (integer, default 50, max 200), cursor, rep |
GET /events/summary | read:events | from, to |
type accepts lead, application, booking, no_show, sale, cancel, payment, sale_lost. Without type, all events are returned. sale groups sales and payments; application includes qualified applications; booking includes marketing and follow-up bookings. rep filters on the assigned rep's name.
events returns an array from newest to oldest, one row per event: id, event_type, event_datetime, contact_email, contact_name, contact_phone, contact_id, value, amount, product_key, product_name, assigned_user_name, is_paid, payment_status, booking_date, booking_time, utm_source, utm_campaign, utm_content, campaign_name, calendar_name, meta_ad_id, is_baseline, cancelled_at, has_more, counted, drop_reason, group_key, contact_seq.
Cursor pagination: has_more is true on the rows when a next page exists. To fetch it, call the route again with cursor set to the event_datetime of the last row received (ISO 8601 timestamp): the next page only contains events strictly older than that.
events/summary returns an array per ad and campaign: ad_name, campaign_name, leads, applications, bookings, sales, no_shows, cancellations, total_value.
curl "https://app.durum.ai/api/v1/events?type=sale&from=2026-08-01&to=2026-08-31&limit=100" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"
# next page
curl "https://app.durum.ai/api/v1/events?type=sale&from=2026-08-01&to=2026-08-31&limit=100&cursor=2026-08-14T15:02:11Z" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Sales and finance
| Route | Scope | Parameters |
|---|---|---|
GET /sales/summary | read:sales | from, to |
GET /sales/reps | read:sales | from, to |
GET /sales/cycle | read:sales | from, to |
GET /finance/pnl | read:finance | from, to |
Responses:
sales/summary: an object (sales KPIs, pipeline, no-show).sales/reps: an array, one row per rep:rep_name,bookings,meetings,shows,no_shows,sales,revenue,close_rate,no_show_rate,ticket_moyen,contacts_met,contacts_closed.sales/cycle: an object:avg_days,median_days,p25_days,p75_days,sample_size,conversions,weekly_trend.finance/pnl: a P&L object. On top of the global list of fields never returned, this route strips every field whose name containsmargin,profit,wholesale,markup,cost_oforcogs.
curl "https://app.durum.ai/api/v1/sales/reps?from=2026-08-01&to=2026-08-31" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Funnel
| Route | Scope | Parameters |
|---|---|---|
GET /funnel | read:funnel | from, to |
Returns an object: funnel stages, conversion by source, campaign and product, time to conversion.
curl "https://app.durum.ai/api/v1/funnel?from=2026-08-01&to=2026-08-31" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Contacts
| Route | Scope | Parameters |
|---|---|---|
GET /contacts | read:contacts | from, to, limit (integer, default 100, max 500), offset (integer, default 0), search |
GET /contacts/customers | read:contacts | none |
GET /contacts/purchases | read:contacts | from, to |
GET /contacts/calls | read:calls | email (required) |
Responses:
contacts: an array, one row per contact:email,total_events,event_types,first_event_date,last_event_date,name,phone,sources,assigned_user_name,value,form_titles,has_campaign,tags,form_response. Paginated withlimitandoffset.contacts/customers: an array, one row per customer:id,contact_email,contact_name,contact_phone,contact_id,first_event_type,first_event_date,latest_lead_date,first_payment_date,commission_status,commission_expires_at,total_revenue,total_purchases,total_commission,products,created_at.contacts/purchases: an array, one row per purchase:id,purchase_date,product_key,product_name,contact_email,contact_name,amount,commissionable,commission_rate,commission_amount,invoice_id.contacts/calls: the calls of the last 365 days this email took part in, with their analysis. Same object ascallsbelow, pluscontact_email. Withoutemail, the response is an empty array.
curl "https://app.durum.ai/api/v1/contacts?search=example.com&limit=200&offset=0" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Calls
| Route | Scope | Parameters |
|---|---|---|
GET /calls | read:calls | from, to, limit (integer, default 50, max 200), offset (integer, default 0) |
GET /calls/transcript | read:calls | id (integer, required) |
GET /calls/recording | read:calls | id (integer, required) |
calls is the entry point: an array of analyzed calls from newest to oldest, one row per call: artifact_id, starts_at, title, duration_sec, call_analysis_id, summary, call_type, is_qualified, icp_fit_score, qualification_reasoning, next_step_type, next_step_summary, next_step_proposed, scorecard_total, rep_id, markers (array of { ts, label, status }). The artifact_id is the id for the two routes below.
calls/transcript returns the raw transcript: an ordered array of segments, each with idx, start_ms, end_ms, speaker_id, speaker_label, speaker_email, text, confidence. If the call does not belong to your account, the response is 403 forbidden. Without id, the response is an empty array.
calls/recording does not return the file: it returns signed URLs valid for one hour, to download directly afterwards:
{
"data": {
"artifact_id": 1234,
"content_type": "audio/mpeg",
"expires_in": 3600,
"audio_url": "https://…",
"video_url": null,
"has_video": false
}
}Errors specific to calls/recording: 400 invalid_id (id missing or not positive), 403 forbidden (call from another account or nonexistent), 404 no_recording_available (no audio or video file).
curl "https://app.durum.ai/api/v1/calls?from=2026-08-01&to=2026-08-31&limit=50" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"
curl "https://app.durum.ai/api/v1/calls/transcript?id=1234" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"
curl "https://app.durum.ai/api/v1/calls/recording?id=1234" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Journeys and touchpoints
| Route | Scope | Parameters |
|---|---|---|
GET /journeys | read:journeys | from, to, limit (integer, default 50, max 200), offset (integer, default 0), search (email fragment) |
GET /journeys/contact | read:journeys | email (required), pageviews (true or 1 to include page views) |
GET /touchpoints | read:journeys | from, to, limit (integer, default 100, max 500), offset (integer, default 0), kind (web or conversion) |
These routes expose the UTM captured at each touchpoint, not only the first or the last one.
journeys: an object{ coverage, paging, journeys }.coveragesays since when web touchpoints exist (web_touchpoints_since): before that date, the absence of a web touch does not mean the absence of a visit.pagingholdslimit,offset,returned. Each item ofjourneys:contact(email,phone,first_name,last_name),first_seen_at,last_seen_at,first_touchandlast_touch(occurred_at,event_type,is_paid,platform,utm,ad_ids),touchpoint_counts(web,conversion,total),milestones(first_lead_at,first_booking_at,first_sale_at,revenue),event_types_seen.fromandtofilter on the contact's last activity date.journeys/contact: an object{ contact, first_touch_at, last_touch_at, counts, touchpoints }.countsholdstotal,web,conversion,page_view.touchpointsis the full chronological chain: each point carries the UTM (utm), the ad ids and click ids captured at that moment.touchpoints: an object{ paging, touchpoints }, newest first, for syncing into your warehouse. Each point carries among othersutm,ad_ids,click_ids,channel,value,currency,product,is_imported. Akindother thanweborconversionreturns200with{ "error": "invalid kind (web|conversion)", "touchpoints": [] }.
The utm object holds source, medium, campaign, content, term, campaign_name, adset_name, ad_name, channel, type, sequence, step.
curl "https://app.durum.ai/api/v1/journeys/contact?email=jean@example.com&pageviews=true" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Creatives
| Route | Scope | Parameters |
|---|---|---|
GET /creatives/requests | read:creatives | status, unnotified (1 or true), limit (integer, default 50, max 200) |
Creative needs are the requests DURUM writes when creatives are missing. Without status, the route returns the requests in progress (open and partial); with status, only that exact status. unnotified=1 returns only the requests never relayed to a team. Response: an array from oldest to newest, each item with id, reason, status, requested_count, filled_count, context, created_at, notified_at.
The intended flow for an external tool: read with unnotified=1, relay to the team, then acknowledge with POST /creatives/requests/ack (see Writes). The acknowledgement only sets notified_at; it does not close the request.
curl "https://app.durum.ai/api/v1/creatives/requests?unnotified=1" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"ICP and pixels
| Route | Scope | Parameters |
|---|---|---|
GET /icp/criteria | read:icp | none |
GET /capi/pixels | read:capi | none |
icp/criteria: the JSON object of your ideal customer criteria.capi/pixels: your connected Meta pixels and the default one (routing configuration). Access tokens are never returned: every field whose name containsaccess_token,secretorapi_keyis stripped before sending.
curl "https://app.durum.ai/api/v1/capi/pixels" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY"Writes
Writes take a JSON body. An invalid body returns 400 with the validation message in error. They only act on your account.
| Route | Methods | Scope | Body |
|---|---|---|---|
/goals | PUT, POST | write:goals | { "goals": [ { "metric_key": "…", "target_value": 0 } ] } |
/sale-rule | PUT, POST | write:settings | { "sale_rule": { … } } |
/icp | PUT, POST | write:icp | { "icp": { … } } |
/ads/rules/enable | POST, PUT | write:ads | { "rule_id": "…", "enabled": true } |
/ads/rules/dry-run | POST, PUT | write:ads | { "rule_id": "…", "dry_run": true } |
/ads/rules/delete | POST | write:ads | { "rule_id": "…" } |
/creatives/upload-url | POST | write:creatives | { "content_type": "image/png", "label": "…" } |
/creatives/import | POST | write:creatives | { "external_ref": "…", "creative": { … } } |
/creatives/requests/ack | POST | write:creatives | { "ids": [ "uuid", … ] } |
A method not listed for the route answers 405 method_not_allowed.
Goals, sale rule, ICP
/goals:goalsis an array of{ metric_key, target_value }objects. Each goal is saved on the monthly period. A body withoutgoalsreturns400 missing goals object. On success,datais{ "success": true }./sale-rule:sale_ruleis your sale counting rule object (how a sale is counted and deduplicated). Body withoutsale_rule:400 missing sale_rule object./icp:icpreplaces your ICP criteria. Body withouticp:400 missing icp object.
curl -X PUT "https://app.durum.ai/api/v1/goals" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"goals":[{"metric_key":"revenue","target_value":100000}]}'Ad rules
rule_id is required (otherwise 400 missing rule_id). enabled and dry_run are true unless you explicitly send false. Dry-run mode computes without acting on Meta. Deletion is final. A rule that is not yours answers 403 forbidden. Arming a rule for real (dry_run: false) requires a paid tier: on the Discovery plan, the refusal carries the code plan_free_no_autopilot (the API itself already answers 403 plan_free_no_api there).
curl -X POST "https://app.durum.ai/api/v1/ads/rules/dry-run" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"rule_id":"RULE_ID","dry_run":true}'Importing a creative in two steps
An import lands in the reserve of your creative library: it publishes nothing, touches no budget and talks to no ad network. The visual can only point to a file uploaded through the authorization issued in step 1: a foreign URL is refused.
Step 1: POST /creatives/upload-url. Body: content_type required, one of image/jpeg, image/png, image/webp, video/mp4 (otherwise 400); label optional (readable label, sanitized, 60 characters max). The storage path is composed server-side inside your account's folder. Response:
{
"data": {
"asset_url": "https://…/creative-assets/…/my-visual-….png",
"upload_url": "https://…",
"token": "…",
"path": "…/2026/09/my-visual-….png",
"content_type": "image/png"
}
}Then send the file with a PUT to upload_url, with the same Content-Type and the binary file as body. The authorization is short-lived.
Step 2: POST /creatives/import. Body: external_ref required (your stable identifier, 200 characters max) and creative, an object whose recognized fields are angle, primary_text, headline, hook, cta, destination_url, asset_url (the asset_url from step 1, as is), asset_kind (image or video), variant_label, visual_subtype, funnel, product, audience, video_script, source (free provenance object). Response: { "id", "external_ref", "angle", "score", "en_attente" } where en_attente is the number of unpublished creatives in the reserve and score is null until computed.
The import is idempotent: replaying the same external_ref updates the creative instead of creating a second one. An already published creative is never overwritten; its id is simply returned.
curl -X POST "https://app.durum.ai/api/v1/creatives/upload-url" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"content_type":"image/png","label":"september-offer"}'
curl -X PUT "RECEIVED_UPLOAD_URL" \
-H "Content-Type: image/png" \
--data-binary @september-offer.png
curl -X POST "https://app.durum.ai/api/v1/creatives/import" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"external_ref":"drive-2026-09-offer-1","creative":{"angle":"Tax savings","headline":"Cut your taxes","primary_text":"…","asset_url":"RECEIVED_ASSET_URL","asset_kind":"image"}}'Acknowledging creative needs
POST /creatives/requests/ack with ids, a non-empty array of request UUIDs (otherwise 400 missing ids (array of request UUIDs)). Only your never-relayed requests are marked; data is the number of requests updated. Replaying the same batch has no effect.
curl -X POST "https://app.durum.ai/api/v1/creatives/requests/ack" \
-H "Authorization: Bearer durum_sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"ids":["3f2a…","9c1d…"]}'Fields never returned
Before sending, every response is walked at all nesting levels and every field whose name contains one of the following fragments is removed:
- credentials and secrets:
access_token,refresh_token,api_key,apikey,secret,signing,service_role,password,webhook_secret,encryption,tokenenc,bearer; - AI internals:
model,model_name,provider,prompt,system_prompt,temperature,embedding; - internal costs:
cost,cost_estimate,token_count,tokens_used,price_input,price_output; - margins:
gross_margin,net_profit,net_profit_after_ads,wholesale,markup,platform_fee,margin_pct,agency_margin,contribution_margin.
Any text value that looks like an AI model identifier is replaced with DURUM.ai. finance/pnl adds its own fragments (see Sales and finance).
Response codes
| Code | Body | Meaning |
|---|---|---|
200 | { "data": … } | success |
400 | { "error": "…" } | invalid parameter or body; error holds the message (for example missing rule_id, invalid_id, external_ref is required) |
401 | { "error": "invalid_or_missing_api_key" } | key missing, invalid, revoked or expired |
403 | { "error": "insufficient_scope", "required": "…" } | the key lacks the required scope |
403 | { "error": "plan_free_no_api" } | the workspace is on the Discovery plan: the REST API and the MCP server require a paid tier |
403 | { "error": "forbidden" } | the resource does not belong to your account |
404 | { "error": "unknown_route", "route": "/api/v1/…" } | unknown route |
404 | { "error": "not_found" } | account or object not found for this write |
404 | { "error": "no_recording_available" } | calls/recording without a file |
405 | { "error": "method_not_allowed" } | method not supported for this route |
429 | { "error": "rate_limited", "retry_after_seconds": 60 } | rate limit exceeded |
429 | { "error": "too_many_failed_attempts", "retry_after_seconds": 60 } | too many failed authentications from your IP address |
500 | { "error": "tenant_barrier" } | response blocked by the account check; contact DURUM |
502 | { "error": "upstream_error" } | internal error while reading or writing; retry later |