API reference
The Leadgram API exists for one job: letting an affiliate network, an advertiser's CRM, or your own server report conversions back to Leadgram server-to-server, without a human touching the dashboard. That is what a fresh key does out of the box, and it is still the common case. It is no longer the whole surface, though: three endpoints answer an x-api-key today - conversion intake at POST /api/events, reading at GET /api/v1/clicks and GET /api/v1/report, and the MCP endpoint at POST /api/mcp, which hands the same work to an AI client as tools. Everything past conversion intake is granted to a key separately and is off by default.
Authentication
Authenticate every request with the x-api-key header carrying a secret you mint under Settings → API keys. See API keys for how to create, name, rotate and revoke them.
A key is not a session. Rights are off on a fresh key, and a grant is a separate action on the API keys page: it names one organization and lists machine slugs by tier. Each list replaces the previous one whole, so a grant is written in full every time rather than amended. A key issued before grants existed keeps exactly one thing without any grant - POST /api/events over HTTP - and nothing beyond it.
The three tiers, by slug:
- read -
clicks,report. They openGET /api/v1/clicks,GET /api/v1/reportand the two MCP tools behind them. Reading is its own set, not a subset of writing: the two do not overlap. - write -
campaigns,offers,offers:status,landings,landings:status,funnels,click-flows,click-flows:status,events. Creating things, flipping a status, and accepting a conversion. - delete -
campaigns:update,campaigns:delete,offers:update,offers:delete,landings:update,landings:delete,funnels:update,funnels:delete,click-flows:update,click-flows:delete,click-flows:publish,postbacks,postbacks:update,postbacks:delete,ad-spend.
Read delete as irreversible in its consequences, not as the HTTP verb: PUT /api/offers/{id} replaces the row whole and the offer URL is where the money goes, publishing a Click Flow puts a graph into live traffic, and a postback rule points every future conversion at an outside address. The other half of that sentence matters as much: write does not mean reversible. Its worst branch is events, which spends your balance and fires a conversion into an ad account you do not own.
No grant covers bots and their tokens, channels, ad accounts, subscriber broadcasts, billing and top-ups, teammates and invitations, organization deletion, password, email or avatar changes, session revocation, custom domains, or the grant endpoint itself - a key able to widen its own rights is an account takeover, not a feature. A key that calls one of those routes anyway gets 403 api_key_resource_forbidden, which reads as "this one needs a dashboard session", not "ask for the right" - there is no right to ask for. A request to /api/auth/* carrying a key is refused outright with 403.
Some rights can be narrowed to specific campaigns, not all of them. Offers and landings are shared across the whole organization by the way the model is built, and so are funnels, funnels:update and click-flows:update, so a grant that carries a campaign list together with one of those slugs is rejected at once with grant_scope_unsupported_resource - the refusal lands when the right is granted, so it is a rule you meet once instead of a half-truth you discover on a live call. Slugs that do take a campaign list - every campaign slug, every Click Flow slug except click-flows:update, postbacks, ad spend, funnels:delete, events and both reading slugs - are then narrowed everywhere, reports included: totals count what the key can see, not what the organization has.
A key carries only what was granted to it, and on a fresh key that is nothing. Editing, publishing and deletion can be granted, though, and the cost of a leak grows with the tier: keep one key per integration, grant the narrowest tier that does the job, and narrow it to specific campaigns where the right allows it. Treat it as a bearer secret: never paste it into a public repo, a support chat or a shared script, and rotate anything that leaks.
POST /api/events
The conversion intake endpoint: it records a conversion event against a click your organization owns. The body is JSON; the minimum is clickId plus type. It stopped being the only endpoint in the API - reading and MCP are described below - but it is still the only one open to a key with no separate right.
clickId(string, required) - the Leadgram click id, the value that reached you through the{click_id}macro. A click belonging to another organization is rejected.type(string, required) - one funnel or conversion event type:land,bot_start,registration,lead,deposit,ftd,purchase, and the rest listed in Events and conversions.payout(number, optional) - the amount on a paying conversion (deposit,ftd, ...). It feeds the Revenue column in Reports and becomes the conversion value (USD) in Google Ads (conversionValue) and in TikTok (properties.value, ondeposit,ftd,purchase). Meta ignores it: there the value comes fromcustomData, and that field cannot be set over the API - it is stripped frompayloadon the way in. A numeric string like"42.50"is accepted; negatives are rejected.payload(object, optional) - free-form, passed through onto the event.telegram_user_idinside it keys billing and per-user dedupe;bot_username/bot_idname the bot for abot_start.
Send it with curl:
curl -X POST https://leadgram.org/api/events \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-conversion-8813" \
-d '{
"clickId": "tz4k9m2p1xq7v0b3n8s6d5fg",
"type": "deposit",
"payout": 42.50,
"payload": { "telegram_user_id": 123456789 }
}'
Responses:
201- recorded. The body is the created event row.400- the body failed validation: a missingclickId, an unknowntype, a negativepayout. A body that is not JSON at all answers400withInvalid JSON body, checked before any quota is spent.401- the key was sent and not accepted: unknown, revoked, disabled or expired. A request carrying nox-api-keyat all does not get this far. Without the header it is a cookie request, and a mutating cookie request with noOriginheader is turned away earlier with403and the bodyCSRF: Origin header required for mutating requests. That answer means an empty or missing header, not a problem with the click.403- the click was not found or belongs to another organization, answered as{ "error": "Click not found or access denied" }with nocode. Abot_startwhosepayload.bot_idorpayload.bot_usernamenames a bot of another organization is refused the same way. Everything else on this status carries its own machinecode:campaign_scope_forbidden(the click sits in a campaign outside the key's campaign list, or carries no campaign at all while the key has one),api_key_write_not_granted(theeventsslug was never granted to this key),api_key_grant_unverifiable(the grant could not be read, or names no organization),api_key_org_not_member, androle_forbidden- the owner's role is re-read on every single call, so a key stops working the moment its owner stops being an owner or admin. Two codes never appear on this endpoint:api_key_delete_not_grantedandapi_key_resource_forbidden, becauseeventssits on thewritetier and is on the flat list of what a key may reach at all.409org_deleted- the workspace was deleted. Retrying changes nothing until it is restored.413Payload Too Large- the JSON body is over 1 MiB. The size is checked twice, against theContent-Lengthheader and against the real byte length, so understating the header buys nothing.422- abot_startwith no Telegram user id inpayload(bot_start_missing_telegram_user_id); accepting it would double-bill against the start Leadgram already recorded.429- you exceeded the rate limit; back off and retry after the window (see below).503- Redis was unreachable, and on a write by key the limiter fails closed. It shipsRetry-After: 60and noX-RateLimit-*headers, because there is no bucket state to report. It is not a429in disguise: your quota is fine, the counter is not, and a key writing without one is not something we allow.
Reading with a key (v1)
Two endpoints answer a key on the read side, and they are versioned on purpose. Both take the x-api-key header and only that - dashboard cookies are not accepted here, and there is no cookie half to these routes. Both are GET: a POST answers 405 method_not_allowed rather than staying silent about it. Neither ever answers 503, because reading spends nothing irreversibly: when the limiter's Redis is unreachable the request is let through instead of locked out. Refusals share one shape - error, a machine code and a hint - and the code is part of the contract, so it is safe to switch on.
GET /api/v1/clicks
The click feed of your organization, newest first, one page at a time.
date_from,date_to- ISO 8601, both optional. Send neither and you get the last 30 days up to now; send one and the other is filled in with the same 30-day span. Both ends are inclusive and are compared exactly as sent -date_to=2026-09-05means midnight, no end of day is added for you. The widest window either endpoint accepts is 92 days.campaign_id- optional and not validated. A campaign outside the key's scope returns an empty slice rather than a refusal.status- optional, exactly one ofclicked,landed,started,registered,lead,deposit,ftd.tracked- optional, one oftrue,false,all. It defaults totrue, so untracked clicks are absent unless you ask for them.cursor- the opaquenext_cursorfrom the previous page, passed back verbatim.limit- 1 to 1000, default 100. A value outside that range is clamped, not rejected.
The answer is { "data": [...], "next_cursor": ... }. next_cursor is a string while another page exists and null on the last one - that, not an empty data, is how your loop ends. Paging is keyset, on creation time and id together, so pages do not shift under you while new clicks land. There is no total field and there will not be one: a keyset page has no cheap count. Counts live in the report endpoint below.
Every row carries 21 whitelisted fields and nothing else: id, created_at, campaign_id, status, tracked, geo, device, os, browser, ref_id, offer_id, sub1-sub9, revenue. created_at is UTC ISO 8601 with a Z. revenue is a decimal string exactly as the database holds it, or null when nothing on that click has been confirmed as paying yet - do not expect a number. Visitor identifiers and ad-platform click ids (ip_hash, user_agent, visitor_id, fbclid, gclid, ttclid and the rest) are withheld by design, so a leaked key hands over none of them.
curl -G https://leadgram.org/api/v1/clicks \
-H "x-api-key: YOUR_API_KEY" \
--data-urlencode "date_from=2026-09-01T00:00:00Z" \
--data-urlencode "date_to=2026-09-07T23:59:59Z" \
--data-urlencode "status=deposit" \
--data-urlencode "limit=500"
Failures come back with a machine code: 400 invalid_window (an unparseable date, or date_to before date_from), window_too_wide, invalid_status, invalid_tracked, invalid_cursor; 401 api_key_required when the header is absent, unauthorized when the key is not accepted; 403 api_key_read_not_granted when the clicks slug was never granted, plus api_key_scope_unverifiable, api_key_grant_unverifiable, api_key_org_not_member and role_forbidden; 409 org_deleted; 429 rate_limited with Retry-After and X-RateLimit-*.
GET /api/v1/report
One funnel slice over the window, and the only place a key gets exact counts.
dimension- required, exactly one ofcampaign,geo,sub1,offer,day. Anything else is400invalid_dimension.date_fromanddate_to- both required here, unlike the click feed. A script calling with no window would aggregate your whole history on every call, so the endpoint refuses with400window_requiredinstead of guessing. The same 92-day ceiling applies.campaign_id- optional. A campaign outside the key's scope returns an empty slice, not a403: the report does not confirm that someone else's campaign exists.
The answer is { "dimension", "window": { "from", "to" }, "rows": [...], "totals": {...}, "truncated" }. Every row carries key, label, clicks, landed, started, registered, lead, deposit, ftd and revenue. cost and cost_source appear on the campaign, sub1 and day slices only - on geo and offer they are absent entirely rather than null, because there is no spend key to attach them to. Money is a decimal string with two decimals, and revenue with nothing behind it is "0.00".
There is no pagination here and there will not be any. A slice is cut at 500 rows, and hitting that cap is reported inside a successful 200: truncated turns true, code is result_truncated, and a hint tells you to narrow the window or add a campaign_id. Repeating the same request returns the same cut. totals are summed over the whole slice before the cap, so they stay right even when the rows are cut short. "The whole slice" means the whole of what the key can see: the campaign list from its grant goes into the same WHERE as the rows, so on a narrowed key totals are the totals of its campaigns, not of the organization.
Beyond the two codes above, the refusals are identical to the click feed, except the slug being asked for is report.
What v1 promises
Fields already in v1 are not removed and do not change type. New fields can appear at any time and your client has to ignore the ones it does not know; new values can appear in enumerated fields the same way. Removing a field, changing its type or its meaning, changing the pagination model or the default window would take a v2 - which would live beside v1 rather than replacing it, with any deprecation announced through Deprecation and Sunset headers (RFC 8594) at least 180 days ahead. POST /api/events is deliberately not versioned after the fact: its URL is printed in the guides and sits in deployed integrations.
The MCP endpoint
Conversational clients - Claude, Cursor and other MCP hosts - reach Leadgram at POST https://leadgram.org/api/mcp. The transport is Streamable HTTP without sessions and answers with plain JSON rather than an event stream. A GET on the same address answers 405 with the JSON-RPC error -32000, which is the correct answer rather than a gap: with no sessions there is no server-to-client stream to open. There is not a single permissive CORS header on the route, so a page in a browser does not call it.
Authentication is the same x-api-key header and only that header, so an MCP client cannot ride on a browser login. Refusals arrive in JSON-RPC shape instead of the usual { error, code, hint } body: -32001 with 401 when the header is missing or the key is unknown, revoked, disabled or expired, and -32002 with 503 when the key could not be checked at that moment - a separate answer on purpose, so nobody reissues a working key over a database blip.
The catalog is 25 tools. tools/list returns the same 25 to every holder of a live key, whatever that key was granted: filtering the list by rights would be a second answer to the question of who may do what, and two answers drift apart quietly. The refusal comes when a tool is actually called, and it names what is missing - Ask a workspace owner or admin to grant "<slug>" to this key. Callers with no key see nothing at all: the catalog sits behind the key check.
Each tool declares the grant slug it needs and the route it stands for, and it is not executed inside /api/mcp: the handler builds the request and hands it to the same route the dashboard uses, so the allowlist, the grant tier, the owner's current role, the rate limit and idempotency all apply in one place instead of being copied. On top of that the slug has to sit in the matching tier of the grant - a write slug in write, a delete slug in delete - and a mismatch answers api_key_write_not_granted or api_key_delete_not_granted, naming the tier you are short of. Sixteen of the 25 are flagged destructive, which is what makes a well-behaved MCP host ask you to confirm first: every tool on the delete tier, plus leadgram_report_conversion (it spends money and fires a conversion outward) and leadgram_set_click_flow_status (moving a graph out of active drops the live campaigns behind it onto their fallback redirect).
The catalog, by tier and by the slug each tool needs:
- read -
leadgram_list_clicks(clicks),leadgram_get_report(report). - write -
leadgram_report_conversion(events),leadgram_create_campaign(campaigns),leadgram_create_offer(offers),leadgram_set_offer_status(offers:status),leadgram_create_landing(landings),leadgram_set_landing_status(landings:status),leadgram_create_funnel(funnels),leadgram_create_click_flow(click-flows),leadgram_set_click_flow_status(click-flows:status). - delete -
leadgram_update_campaign(campaigns:update),leadgram_delete_campaign(campaigns:delete),leadgram_update_offer(offers:update),leadgram_delete_offer(offers:delete),leadgram_update_landing(landings:update),leadgram_delete_landing(landings:delete),leadgram_update_funnel(funnels:update),leadgram_delete_funnel(funnels:delete),leadgram_delete_click_flow(click-flows:delete),leadgram_publish_click_flow(click-flows:publish),leadgram_create_postback(postbacks),leadgram_update_postback(postbacks:update),leadgram_delete_postback(postbacks:delete),leadgram_record_ad_spend(ad-spend).
Three things to know before you point an assistant at your workspace:
- The compatibility exemption for old keys does not reach MCP. A key issued before grants existed keeps sending conversions over HTTP with no grant at all. The same key calling
leadgram_report_conversiongets403api_key_write_not_granteduntileventsis granted to it, with a hint saying so. The exemption was given to one configured surface,POST /api/eventsover HTTP; the MCP endpoint did not exist yesterday, so no integration is standing on it. The same goes for the role rail: the allowance for keys issued before 2 September 2026 stays on HTTP too, so on MCP an owner who is no longer an owner or admin getsrole_forbidden. leadgram_update_offerandleadgram_update_landingreplace the row whole. Fields you do not send are cleared, and the same key cannot read the current values back to fill them in.leadgram_create_postbackcreates GET rules only, and the delivery method cannot be changed afterwards anywhere. A rule that posts is made in the app.
One grantable right has no tool at all: click-flows:update. There is no paired read of a flow graph under a key, so the only branch a conversational client could reach is overwriting a graph blind. Over HTTP that slug is granted like any other.
Postbacks (outbound)
Postbacks run the other direction: for every conversion Leadgram records, it calls your partner tracker's S2S URL server-to-server, so registrations and deposits stay visible in Keitaro or RedTrack where you optimize. A rule is a trigger event, a URL template and a method, saved on Postbacks - or written by a key that was granted the delete tier on postbacks, postbacks:update and postbacks:delete. Grant those deliberately: such a key can repoint an existing rule, and a repointed rule quietly ships every future conversion to somebody else's address. Presets prefill the template: Keitaro, RedTrack, or Custom from blank, with macros like {click_id}, {event_type} and {sub1}-{sub5} substituted on every fire. Payout is not a postback macro - it travels with the event, not the postback. The full macro list, the wiring order and retry behavior are in the Postbacks guide.
Limits & idempotency
The events endpoint allows about 1800 requests per minute per organization. Past that you get 429 with a Retry-After header plus X-RateLimit-* headers naming the ceiling and when it resets - wait out the stated interval rather than hammering.
Reading has its own ceilings, and they are lower because a report costs far more to compute than a page of clicks: 300 requests a minute on GET /api/v1/clicks, 60 on GET /api/v1/report. All three ceilings are counted per organization, not per key, so a second key does not buy a second quota - two keys in one organization share it. Reading answers 429 rate_limited with the same headers.
Retries are safe once you make them idempotent. Send an Idempotency-Key header (or a nonce field in the body) and the event id is derived from clickId + type + that value, so resending the same request never writes a second row or double-fires the charge, the postbacks or the ad-platform conversions. The flip side: two genuinely different conversions on the same click - a second deposit - must carry a different key, or the second one collapses into the first and disappears. See Events and conversions for the dedupe model in full.