Developers & agents

API, webhooks and MCP server

One key, one header, and links, attribution, and sales become HTTP calls. The v1 API, the MCP server, and the OpenAPI description all come from the same registry: one truth to learn, and it cannot drift from itself.

Create an account and a key

Your first request

201

curl -X POST https://subtraq.co/api/v1/links
{
  "id": "clx8f2h4k0001a1b2c3d4e5f6",
  "slug": "b7kQm2x",
  "label": "Pub Meta — mars",
  "destination": "https://exemple.fr/offre",
  "parentId": null,
  "status": "active",
  "utmSource": "meta",
  "utmMedium": "cpc",
  "utmCampaign": "mars",
  "utmTerm": null,
  "utmContent": null,
  "createdAt": "2026-09-11T09:14:02.181Z",
  "space": "maison-lartigue",
  "domain": "subtraq.co",
  "shortUrl": "https://subtraq.co/b7kQm2x"
}

A link created via the API redirects instantly: the edge lookup table is rewritten before the response goes out. And shortUrl is handed back to you ready-made — never assemble it yourself, the link’s domain is not always ours.

The same thing, via MCP, for an agent

Authentication

API key authentication

Every call carries Authorization: Bearer followed by the agency key, in the form stq_sk_…. It is created in Settings → API Keys, shown once, and never stored in plain text: only its SHA-256 fingerprint is kept. Revocation is immediate and applies to both the API and the MCP server.

No header: 401 missing_token. Unknown or revoked key: 401 invalid_token. Key missing the required scope: 403 missing_scope — never a partial result you would have to guess at.

curl -X POST https://subtraq.co/api/v1/links \
  -H "Authorization: Bearer stq_sk_VOTRE_CLE_SECRETE" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: lien-mars-01" \
  -d '{
    "space": "maison-lartigue",
    "destination": "https://exemple.fr/offre",
    "label": "Pub Meta — mars",
    "utmSource": "meta",
    "utmMedium": "cpc",
    "utmCampaign": "mars"
  }'

links:read

List workspaces and links, detail a link with its final destination and its clicks over thirty days.

links:write

Create a workspace, a parent link or a placement, update a destination or a label, archive.

analytics:read

Read clicks, leads, sales, attributed and unattributed revenue, placement by placement.

events:write

Record a sale. The only scope that touches money, and it can only be exercised from a server.

A key can also carry *, which opens all four — reserve that for your own scripts.

What the product keeps, and what it does not

The contract

Four rules common to all routes

1

Amounts are in minor units

$4,300 is written 430000. No float ever circulates over money: amount is an integer, between 0 and 1,000,000,000. Subtraq does not convert currencies — if a workspace contains several, mixedCurrencies is true and the breakdown is in byCurrency.

2

Lists are paginated with a cursor

limit runs from 1 to 100, fifty by default, and any other value is rejected. Follow nextCursor until null. The cursor is opaque: pass it through, never build it.

3

Writes accept Idempotency-Key

A replay returns the original response, with the x-subtraq-idempotent-replay: true header. Only successes are cached: a failed call can be retried. For a sale, invoiceId plays this role, and it is required.

4

One error shape

Always an error object with a code and a message, plus a details field when a parameter is invalid. Wire your code on code, which is stable; show message to the person.

{
  "error": {
    "code": "slug_taken",
    "message": "Le raccourci « promo-mars » est déjà pris."
  }
}

The most common codes: slug_taken and slug_reserved when creating a link, space_not_found when the workspace does not belong to the key, space_limit_reached as a 402 when the plan is full. That last one is not an outage: do not retry it in a loop.

What each plan allows

Imported from the registry, not from a brochure

The 8 API v1 endpoints

Every row below is read from the same registry the server actually exposes. Responses carry the x-subtraq-api-version header.

GET /api/v1/spaces

links:read

Lists the agency's client workspaces. One workspace = one end client, brand or project; everything else (links, clicks, sales) lives inside it.

POST /api/v1/spaces

links:write

Creates a client workspace. Refused with code `space_limit_reached` when the plan is full.

GET /api/v1/links

links:read

Lists links. A PARENT link carries the destination; a PLACEMENT (sublink) carries the UTMs of one specific post and inherits the rest.

POST /api/v1/links

links:write

Creates a short link. Without `parentId` it is a parent link and `destination` is required. With `parentId` it is a placement: it inherits the parent's destination and carries its own UTMs. Returns `shortUrl`, ready to publish.

GET /api/v1/links/{id}

links:read

Details a link: its FINAL destination (UTMs included, exactly as the landing site receives it) and its clicks over 30 days.

PATCH /api/v1/links/{id}

links:write

Changes a link's destination or label, or archives it. A link is NEVER deleted: archived, it stops redirecting but its history stays.

GET /api/v1/analytics

analytics:read

A workspace's numbers: clicks, leads, sales, ATTRIBUTED revenue and UNATTRIBUTED revenue, plus the detail placement by placement. Amounts in CENTS. Subtraq does not convert currencies: if `mixedCurrencies` is true, the totals only cover `currency`. `model` picks how attribution is read; the response always says which one was used.

POST /api/v1/sales

events:write

Records a SALE and ties it back to the person's originating placement. Amount in CENTS. `invoiceId` makes the call idempotent: replaying it never bills twice. A sale can NOT be sent from a browser.

The full description, in OpenAPI 3.1, is generated from that same registry: /api/v1/openapi.json.

The questions we get most often

Outbound

Signed webhooks on every conversion

As soon as a lead or a sale is recorded, Subtraq posts this JSON to the address you gave. A webhook applies to one client workspace or to the whole agency, and you choose what it receives: lead, sale, or both. That is exactly what a “receive a webhook” trigger expects, in Zapier as in Make or n8n — nothing to install on our side.

A null placement is not an oversight: it is a conversion Subtraq could not attribute, and it says so rather than inventing an origin.

The address must be https and public: local addresses and private ranges are rejected, otherwise a webhook would become a probe aimed at our own network. Two attempts, five seconds each, then the failure is logged next to the webhook. A failed delivery never fails the conversion.

The step-by-step guide on the Zapier side

{
  "event": "sale",
  "at": "2026-09-11T09:14:02.181Z",
  "space":     { "slug": "maison-lartigue", "name": "Maison Lartigue" },
  "person":    { "email": "[email protected]", "externalId": null },
  "placement": { "slug": "b7kQm2x", "label": "Pub Meta — mars" },
  "amountMinor": 430000,
  "currency": "EUR",
  "eventId": "clx9a1b2c0002d3e4f5g6h7i8"
}

Both directions

Signed outbound. Verified inbound.

What we send

The signature on our outbound webhooks

The X-Subtraq-Signature header carries t=TIMESTAMP,v1=SIGNATURE, where the signature is the HMAC-SHA256 of TIMESTAMP.BODY computed with the secret shown at webhook creation. The timestamp enters the computation: without it, a captured signature would stay valid forever. Verify with a constant-time comparison, and reject after five minutes.

What we receive

Your client’s Stripe webhook

Your client pastes an address in their Stripe dashboard and their sales arrive, with zero lines of code. The Stripe signature is required: the address key grants no access on its own — the signature alone authorizes. Three event types are handled — checkout.session.completed, invoice.payment_succeeded et invoice.paid. The rest receives a 200 and passes: an error would make Stripe retry forever.

X-Subtraq-Signature: t=1757580842,v1=9f2c…e41
User-Agent: Subtraq-Webhook/1.0
Content-Type: application/json

The same Stripe payment can arrive via two event types: deduplication therefore relies on the payment identifier, the only key that holds across both. Stripe amounts are already in the currency’s smallest unit, as they are here — no conversion, so no chance of being off by a factor of one hundred.

Connecting Stripe, screen by screen

Browser side

One tag. It configures itself.

One tag, placed on your client’s site. It picks up st_id from the landing URL, stores it, and also keeps an ordered list of subsequent clicks — the journey, up to twenty. The first click holds its position no matter what.

<script src="https://subtraq.co/subtraq.js" async
        data-key="stq_pk_VOTRE_CLE_PUBLIABLE"
        data-endpoint="https://subtraq.co"></script>

Three calls are all you need: lead(email) when a person leaves their address, identify(externalId) when they have an account with you, track(nom) for a free-form event. And decorate(url) reattaches the click identifier to an outbound link, to follow someone across domains.

document.querySelector('#mon-formulaire')
  .addEventListener('submit', function () {
    window.subtraq.lead(document.querySelector('[name="email"]').value);
  });

These calls target /api/t/lead et /api/t/event, authenticated with the publishable key stq_pk_… — the one that can be read in the HTML, because it opens no read access. No amount is accepted here: a figure written from a browser is a figure anyone can write. You restrict the allowed domains from the setup screen; outside the list, the response is 403 origin_not_allowed. No device fingerprint, no third-party cookie.

Tools already connected, without writing code

Served by the API itself

Three documents. Written by the code.

A file sitting in a repository goes stale in silence. These three are regenerated on every call from the registry: they cannot lie about what the server does today.

/api/v1/openapi.json

The OpenAPI 3.1 description: paths, parameters, bodies, responses, and the scope required per operation. Enough to generate a client or feed a testing tool.

/api/v1/agent-skill.md

The usage contract written for an agent: the model in three sentences, the most common flow, the rules to know, and the parameter table.

/api/mcp

The MCP server, in JSON-RPC 2.0 over HTTP: initialize, tools/list, tools/call, with the same Bearer key. A GET returns the server’s business card.

The MCP server has no separate implementation: it calls the same route handlers as the API. Two implementations would have diverged at the first fix, and an agent would have gotten a different result depending on which door it used. Claude, Cursor, or Zed: the same key, the same result.

What an agent actually does with these tools

Developer questions

Do I need to install an SDK?

No. The API is plain HTTP and JSON: curl, fetch, requests — any client works. A JavaScript file exists for the browser, subtraq.js, but it only handles lead tracking on your client’s site. The API itself can be called from anywhere.

How do I avoid creating the same object twice?

Add an Idempotency-Key header to your writes. A replay returns the original response, with x-subtraq-idempotent-replay: true. For a sale, the invoice number invoiceId plays this role, and it is required: replaying a webhook never bills twice.

Can a key see another agency’s data?

No. Every call resolves the requested workspace inside the key’s agency. A workspace that belongs to someone else is simply not found: the response carries a 404, never a result that might suggest the workspace is empty.

Can a sale be sent from the browser?

No — it is refused by design: the browser-side tracking routes accept no amounts. A figure written from a page is a figure anyone can write, and the secret key must never reach a browser.

What happens if a webhook does not arrive?

Two attempts are made, five seconds each, then the failure is logged next to the webhook with its status, message, date, and consecutive failure count. There is no queue: a destination unavailable for several minutes loses the deliveries from that period. The conversion itself remains recorded.

Can a link be deleted via the API?

No: we archive. An archived link stops redirecting, but its history of clicks, leads, and sales remains readable. Deleting it would erase figures already presented to a client.

Create your API key

One account, one key, one curl: the 201 lands and the link is already redirecting. The rest — webhooks, Stripe, MCP — connects when you need it, not before.

Create a free account