What developers ask before and while connecting: keys, links, how the decision reaches your site, webhooks, the journal, limits and errors. Every method and number here is the one the API uses today.
Register and confirm your email. The first page after sign-in, «Connect via API», creates your key, clk_live_….
The key is shown once, right after you create it, so copy it then: we keep only its hash. An account can hold up to 10 active keys. A key you no longer need is revoked on the same page and stops working at once.
The API lives at https://api.consent.link/v3 and speaks JSON. Send the key with every request in the header Authorization: Bearer clk_live_… (the X-AUTH-TOKEN header of our old API v2 works too). A request without a valid key gets 401 unauthenticated.
Call the API from your server only. Whoever holds the key can create links and read your journal with full visitor data.
Send POST /v3/links with the destination. The answer is 201 with the link: short is the address to put in your ads, posts and emails, id is its code. Only destination is required.
Links are made only through the API, never in the cabinet: every destination is checked when a link is created, which keeps phishing out.
destination: where the visitor goes after deciding. Required.
type: the label on the consent page, one of ad, sponsored, affiliate, none. Without it, the label chosen in «Design».
template: the text of the page, a template id from our library (the table below). Without it, the template chosen in «Design».
name: your own name for the link, up to 255 characters, shown in the cabinet.
external_id: your own id, for example of a post or a campaign, up to 255 characters. You can filter links by it; it can't be changed later.
metadata: a JSON object of your own, kept and returned as it is.
The answer also carries resolved_destination (for a tracker or a short link, see below), status (active, disabled or blocked), mode (live or test) and created_at; warnings only when there is something to warn about.
An absolute http or https address on a public domain name, up to 2,048 characters: no IP addresses, no user name or password, no spaces or backslashes. We keep it byte for byte.
Every new address is checked against our block list and Google Web Risk, and a dangerous one is refused with 422 destination_blocked. If an address turns out to be dangerous later, the link is blocked: it stops opening, it can't be changed any more, and your webhook gets link.blocked.
When the destination's host is the click address of an affiliate network or a link shortener, such as awin1.com, sjv.io, prf.hn, admitad.com, amzn.to, bit.ly (subdomains too), we follow its redirects ourselves: when the link is created, also in bulk, and when PATCH changes its destination. That is up to 5 redirects within a few seconds. The address where the chain leaves the trackers for another site is saved, byte for byte, as resolved_destination. Other destinations are never fetched, and their resolved_destination stays null.
destination stays exactly as you sent it. With consent the visitor goes there, through the tracker. Without consent they go straight to resolved_destination, past the tracker, with the ad click IDs removed.
If the chain can't be followed (the tracker doesn't redirect, is too slow, redirects too many times, leads to an internal or blocked address, or never leaves the trackers), the link is created anyway, but a refusal goes through the tracker for now. Every answer that shows the link then carries warnings with the code tracker_unresolved, and the link's page in the cabinet says so too. We try again in the background, waiting 5 minutes, 1 hour between the tries; once it works, resolved_destination is filled in and the warning goes away. After the last try, check the address and send it again with PATCH. When there is nothing to warn about, the answer has no warnings.
Our requests to trackers carry the User-Agent Consent.link-Resolver/1 (+https://consent.link/faq/api).
Answer
201 Created
{
"id": "pR3xd",
"short": "https://consent.link/pR3xd",
"destination": "https://www.awin1.com/cread.php?awinmid=1234&awinaffid=56789&ued=https%3A%2F%2Fshop.example%2Fboots",
"resolved_destination": null,
"type": "affiliate",
"template": "statsads",
"name": "Winter boots",
"external_id": "post-8815",
"metadata": {"campaign": "winter"},
"profile": "main",
"status": "active",
"mode": "live",
"created_at": "2026-11-03T12:00:00.000Z",
"warnings": [
{
"code": "tracker_unresolved",
"message": "The destination is a tracker redirect that could not be unwound yet: a visitor who refuses will go through the tracker. It is tried again later; resolved_destination is filled in when it works."
}
]
}
Send an Idempotency-Key header with POST /v3/links and POST /v3/links/bulk: any 1 to 255 printable ASCII characters, for example a UUID or your own id of the post. A repeat with the same key and the same body within 24 hours returns the links already made, with the header Idempotent-Replayed: true, and creates nothing.
The same key with a different body is refused with 422 idempotency_key_reused. If the first request is still running, the repeat gets 409 idempotency_in_progress: wait a moment and send it again.
Yes: POST /v3/links/bulk with {"links": [ … ]}, up to 500 links per request, each with the same fields as a single link. The answer is 201 with data, the links in the order you sent them.
It is all or nothing: if one item is invalid, nothing is created and the error names it, for example links.3.destination.
PATCH /v3/links/{id} with only the fields to change: destination, type, template, name, metadata. null clears an optional field, metadata is replaced as a whole, and external_id stays as it was created. The consent page shows the change at once. A new destination is checked like a new link's, and if it is on another domain, earlier «Don't ask again» consents for the old domain no longer let visitors through.
DELETE /v3/links/{id} switches the link off: it stops opening and can't be changed or switched on again, while its records stay in the journal. Make a new link if you need it back.
GET /v3/links lists your links, newest first, 50 per page (up to 100 with limit). Filters: status, mode, type, template, external_id, domain, and from and to for the creation time (2026-10-31 or 2026-10-31T14:00:00Z). For the next page, pass next_cursor from the answer as cursor; null means there are no more pages.
GET /v3/links/{id} returns one link with stats since it was created: shown, granted, denied, left (left the page without deciding), reused and withdrawn. The numbers are a few minutes behind the journal.
The page you set up in «Design» in the cabinet: the text template from our library, the data recipients, the label, the link to your privacy policy, and on Business your logo and colour. The link's own type and template take precedence over the design. When you publish a new version, the pages of your links change within a minute.
The language is picked from the visitor's browser: English, Spanish, German, French, Italian, Portuguese or Russian. You choose a template, not words: the texts come from our library, so you can't write your own.
Until something is published in «Design», the page shows no link to your privacy policy and no data recipients. Links are still created, and the responses to POST /v3/links, POST /v3/links/bulk and PATCH /v3/links/{id} carry the header Consent-Link-Warning: design_unpublished while the link's configuration has no published version.
Save several profiles of the page in «Design» or through the API, each with its own id such as black-friday, and name one when you create a link: "profile": "black-friday" in POST /v3/links or in a bulk item. Without profile the link gets your default profile.
A link keeps its profile: making another profile the default later doesn't move old links. PATCH /v3/links/{id} with profile moves one link, and "profile": null moves it to the default of that moment. An unknown id is a 422 on the field profile. The link object shows its profile, and GET /v3/links?profile=black-friday lists one profile's links. How many profiles you can have depends on the plan: 1 on Free, 5 on Pro, 50 on Business, the default included.
GET /v3/profiles lists them, default first. POST /v3/profiles creates one with id and policy_url (required) and, if you like, name, template, vendors, type, remember, brand_name and default. It is published at once. PATCH /v3/profiles/{id} changes it: the page fields publish a new version straight away, name and "default": true alone publish nothing.
DELETE /v3/profiles/{id} removes a profile that isn't the default (409 profile_is_default) and has no active links (409 profile_in_use). Creating one over the plan limit, or using one that is over the limit after a downgrade, gets 402 profiles_limit_reached. Changes need a live key.
With consent the visitor goes to your destination exactly as you gave it. Without consent utm_* and your other parameters stay too, and only the ad click IDs are removed: gclid, gbraid, wbraid, fbclid, msclkid, ttclid, twclid, li_fat_id. In both cases we add one parameter, cl_ct, the decision token.
Only the destination's own parameters count: anything added to the short link itself (https://consent.link/nk7Qa?utm_source=…) is not passed on. Put everything your site needs into destination, or create a link per campaign.
Where the visitor goes: with consent, then without
Every redirect from the consent page carries cl_ct, a signed token (JWT, ES256) that lives 10 minutes. Check it from your server with POST /v3/consents/verify: the answer has the decision, and each token can be checked once, so a copied address is worthless. Read the token, then remove it from the address.
What it holds: decision (granted or denied), purposes (what the visitor allowed), cid (the journal record), sid (the visitor's pseudonym in your account, the same on all your links), cexp (until when the consent holds, Unix time), link, mode, iat and exp. No IP, browser or other personal data. A visitor who agreed earlier and kept «Don't ask again» goes straight through, with granted.
You can also check the signature yourself with the keys at https://consent.link/.well-known/jwks.json and any JWT library: check exp, and that link is one of yours. Your own check can't tell whether a token was already used; the API method can. Errors: 422 token_invalid, 422 token_expired, 409 token_used.
Create a webhook: POST /v3/webhooks with url and the events you want (the table below). The address must be https on a public host name. The answer has secret (whsec_…) for checking signatures: it is shown once, so save it. An account can have up to 10 active webhooks.
The delivery log, the «Retry» button and switching a webhook off or on are in the cabinet under «Account» → «Webhooks».
Every event is a POST with a JSON body: id (evt_…), type, created_at, mode and data. A consent record in data.consent has id, link, decision, purposes, scope (domain when the visitor kept «Don't ask again», else link), sid, dest_domain, country, notice_version, created_at and expires_at. Never an IP, browser or cookie.
Event
When
data
consent.recorded
A new journal record: an agreement, a refusal or a visitor who agreed earlier
consent
consent.withdrawn
A consent was withdrawn: by the visitor on «My consents», or by you through the API
consent, withdraws
link.blocked
We blocked a link because its destination turned out dangerous
Each request carries the headers Consent-Event, Consent-Delivery and Consent-Signature: t=1793712000,v1=5f3a…. v1 is the hex HMAC-SHA256 of the text t, a dot and the raw request body, keyed with your webhook secret.
Compute it from the body exactly as received (before any JSON parsing), compare in constant time, and refuse a t more than five minutes away from your clock, so a recorded request can't be replayed.
A delivery counts when your server answers 2xx within 10 seconds; redirects are not followed. Otherwise we try again, waiting 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours between the tries, 6 tries in all. After the last one the delivery is marked failed and stays in the log, where «Retry» sends it again for up to 3 days after the event.
So answer fast and do the work afterwards. The same event can arrive more than once, for example after a timeout: skip ids you have already processed. Events don't always arrive in the order they happened.
Visitors see and withdraw their consents on «My consents». If a visitor asks you instead, withdraw it for them: POST /v3/consents/{id}/withdraw, no body, with the id of their granted or reused record. It works for any consent still in force, however far back your plan shows the journal. The answer is 201 with the new withdrawn record, whose first_consent_id is the consent taken back. A record that is not a consent in force (refused, already withdrawn or expired) gets 409 consent_not_in_force; one you can't read gets 404, as with GET /v3/consents/{id}.
Both ways end the same: a withdrawn record in the journal, consent.withdrawn to your webhook with data.withdraws, the id of the consent taken back, and from then on your links ask that visitor again. A withdrawal closes every consent the visitor gave for that domain before it, and it doesn't count towards your limit.
Stop using the visitor's data on your side: match by sid or by the record id.
Request
curl -X POST https://api.consent.link/v3/consents/01jbz8m3k4x9w2r7d5t6q8v1yh/withdraw \
-H "Authorization: Bearer clk_live_…"
A consent holds for 6 months from when it was given: expires_at in the record, cexp in the decision token. A reused record carries the expires_at of the consent it reuses. After that, your links ask the visitor again.
When a granted consent lapses without having been withdrawn, your webhook gets consent.expired with data.consent, the record, and data.expired_at. We check once an hour, so it arrives within the hour after expires_at, once per consent. reused records get no event of their own: the one for the consent they reuse covers them. The event goes only to webhooks with consent.expired in events. An expiry is announced once, even if no webhook was waiting for it then: it isn't sent again later.
Put our script in <head>, before GTM and gtag: https://consent.link/js/consent.js. It sets the Consent Mode v2 default (everything denied but functionality_storage and security_storage, with wait_for_update 500 ms), reads cl_ct from the address, checks its signature against the keys at https://consent.link/.well-known/jwks.json right in the browser, and updates consent.
The decision is kept in the cl_consent cookie on your domain: until cexp for a consent, 180 days for a refusal, so the next pages need no banner. Once checked, cl_ct leaves the address bar; your other parameters stay. The script sends us nothing.
If you record the decision on your side, still check the token from your server with POST /v3/consents/verify: the script can't know whether a token was used before.
Download the template consent-link.tpl; in GTM open Templates → New → ⋮ → Import and save it. Create a tag from it with the «Consent Initialization – All Pages» trigger.
The tag sets the defaults, applies the decision stored in the cl_consent cookie and loads the script, which checks a fresh cl_ct. You then don't need the script in <head> as well.
The purposes of the token grant these signals. Anything not granted stays denied; a refusal (denied) denies everything but the two that are always granted.
Don't show a second banner to someone who has decided on our page. The script gives the decision in window.consentLink.decision (granted, denied or null) and window.consentLink.has('stats'); window.consentLink.ready resolves once a token in the address has been checked. There is also the consentlink:update event on document and consent_link_update in dataLayer. Load our script before the banner's.
Cookiebot: pass the decision on with Cookiebot.submitCustomConsent and its banner stays away. OneTrust has no public function to record consent from the site, so load its script only when there is no decision. Other banners the same way: pass the purposes on through their API, or don't load the banner.
Cookiebot
window.addEventListener('CookiebotOnLoad', function () {
window.consentLink.ready.then(function (cl) {
if (cl.decision && !Cookiebot.hasResponse) {
Cookiebot.submitCustomConsent(cl.has('personalization'), cl.has('stats'), cl.has('ads'));
}
});
});
OneTrust
window.consentLink.ready.then(function (cl) {
if (cl.decision) return; // decided on consent.link: no second banner
var s = document.createElement('script');
s.src = 'https://cdn.cookielaw.org/scripttemplates/otSDKStub.js';
s.setAttribute('data-domain-script', 'YOUR-ONETRUST-ID');
document.head.appendChild(s);
});
GET /v3/consents lists records newest first, with the filters decision (granted, denied, reused, withdrawn), link, country (DE), from, to and the same paging as links. GET /v3/consents/{id} returns one record with its evidence (IP address, browser, language, the page text hash, when the page was shown and how long the visitor took) and its place in the hash chain, chain.
A refusal keeps only the decision, the time, the link and the country, so its evidence is null. The API gives the data in full; the cabinet hides part of it until you turn on two-factor sign-in. How far back you see depends on the plan (the table in «Plans and limits»).
GET /v3/consents/{id}/proof returns the record as a PDF: the decision, who, what was shown, the hashes and how to check them. It is in the language of your cabinet. Available on Pro and Business (402 plan_required on Free), up to 20 a minute. If the PDF can't be made right now, the answer is 503 unavailable with Retry-After.
Every granted record carries evidence.snapshot_hash: the SHA-256 of the consent page as the visitor saw it, as HTML. One snapshot serves every visitor of the same page, that is the same design and text versions, language and link type. GET /v3/consents/{id}/snapshot returns that HTML, with the hash in the X-Snapshot-Hash header, and GET /v3/consents/{id}/snapshot.png a screenshot of it. Both follow the rules of GET /v3/consents/{id}, journal depth included.
To check it, compute the SHA-256 of the HTML exactly as received: it equals snapshot_hash, and the record's own hash in the chain covers snapshot_hash, so the page can't be swapped afterwards. What differs from visit to visit is fixed in the snapshot: the destination's host reads example.com, the form's signature is empty and its action is #. The real address is in dest_domain and evidence.destination.
reused records, refusals and records made before snapshots existed have snapshot_hashnull, and their snapshot answers 404 not_found («This record has no page snapshot.»). Right after a decision the files may still be being written: 404 not_found with «The page snapshot is not ready yet.» means try again shortly. The HTML comes within seconds, the screenshot a little later. The record's page in the cabinet and the PDF proof show the screenshot and the hash too.
POST /v3/exports with the filters of GET /v3/consents (decision, link, country, from, to) answers 202 at once with the export's id. The file is made in the background: you get the export.ready webhook, or check GET /v3/exports/{id} until status is ready, then download GET /v3/exports/{id}/file. The export also has rows and the file's sha256.
Files are kept 7 days (410 export_expired after that). Up to 3 exports can be in progress at once (429 too_many_exports). Pro and Business.
GET /v3/stats/summary gives shown, granted, denied, left, reused and withdrawn for UTC days from from to to (2026-10-31, both included; by default the last 30 days, at most 366 days, as far back as your plan shows the journal). Add link for one link. GET /v3/stats/daily gives the same numbers day by day, and on Pro and Business by country with by=country. Bots are never counted.
GET /v3/usage shows this period's consents, your limit, what is remaining, and the period's start and end.
The API, webhooks and the consent page in seven languages are on every plan. The plans differ in consents a month, how far back the journal is shown, the request rate and extra features. Prices in euros, excluding VAT.
Plan
Price a month
Consents a month
Journal
Requests a minute
Page profiles
Also
Free
€0
1,000
Journal for 30 days
60
1
—
Pro
€9
10,000
Journal for 1 year
300
5
CSV export and PDF proof · Statistics by day and country
Business
€99
250,000
Journal for 3 years
1,000
50
CSV export and PDF proof · Statistics by day and country · Your logo and colour · Links on your own domain
Only new agreements count: refusals, repeat visits with «Don't ask again», and bots don't. At 80 % and 100 % of the limit you get an email and the usage.warning webhook.
Once the limit is used up, new live links are refused with 402 limit_reached. Everything else keeps working: existing links still ask and record, webhooks and tokens arrive as usual, and nothing is hidden. Creating links opens again at the start of the next period (on Free the 1st of the month, on paid plans the day of payment) or right after an upgrade.
Per account, all keys together: 60 a minute on Free, 300 on Pro, 1,000 on Business. Over that the answer is 429 rate_limited with a Retry-After header; the headers X-RateLimit-Limit and X-RateLimit-Remaining show where you stand. Some methods have their own limit as well: PDF proofs 20 a minute, new exports 10, export downloads 30.
Always the same shape: the HTTP status and a JSON body with a stable code and an English message. Act on the code; the message may be reworded. Invalid fields give 422 validation_failed with fields: each field with its problems.
Status
Code
Meaning
401
unauthenticated
No key, or the key is wrong or revoked
402
limit_reached
This period's consents are used up: no new live links
402
plan_required
The feature needs a higher plan
402
profiles_limit_reached
Over the plan's number of page profiles
403
account_blocked
The account is blocked: contact support
403
links_blocked
The account may not create links: contact support
403
mode_mismatch
A live link is changed with a live key, a test link with a test key
403
live_key_required
This needs a live key
404
not_found
Nothing with this id in your account
405
method_not_allowed
This HTTP method isn't allowed on this path
409
link_disabled
The link is switched off and can't be changed
409
link_blocked
The link was blocked and can't be changed
409
idempotency_in_progress
A request with this Idempotency-Key is still running
409
token_used
This decision token was already checked
409
export_not_ready
The export is still being made
409
export_failed
The export failed: start a new one
409
consent_not_in_force
The record is not an in-force consent: refused, withdrawn or expired
409
profile_is_default
The default profile can't be deleted
409
profile_in_use
The profile still has active links
410
export_expired
The file is no longer kept: start a new export
422
validation_failed
Some fields are invalid: see fields
422
destination_blocked
The destination is dangerous or blocked
422
idempotency_key_reused
The Idempotency-Key was used with another body
422
token_invalid
Not a valid decision token for your account
422
token_expired
The decision token is older than 10 minutes
422
webhooks_limit_reached
Too many active webhooks
429
rate_limited
Too many requests: wait for Retry-After
429
too_many_exports
Too many exports in progress
429
daily_limit_reached
New Free account: the day's links are made, more from 00:00 UTC
500
server_error
Something failed on our side: try again later
503
unavailable
Try again shortly: see Retry-After
Answer
422 Unprocessable Content
{
"error": {
"code": "validation_failed",
"message": "Some fields are invalid.",
"fields": {
"destination": ["The destination must be an absolute http or https URL."]
}
}
}
Create a new key on «Connect via API», switch your server to it, then revoke the old one: it stops working at once. Revoking a key doesn't touch your links, the journal or webhooks.
Keep keys in your server's environment variables, never in a browser, a mobile app or a public repository. If a webhook secret may have leaked, switch that webhook off in the cabinet and create a new one.
Ready to connect?
Register, create a key on «Connect via API» and send your first request in a few minutes.