Developer Documentation
Install tracking, push revenue, and integrate webhooks. Every example below reflects the live API.
Overview and authentication
The Attrevo API is a JSON HTTP API. All application endpoints are served under https://api.attrevo.com/api.
There are three distinct authentication modes:
| Mode | Used by | Credential |
|---|---|---|
| Session (Clerk JWT) | Dashboard requests from the browser | Authorization: Bearer <jwt> |
| API key | Server-to-server, e.g. the revenue API | X-Attrevo-API-Key: atv_live_… |
| Public | Tracking ingestion, consent, snippet | None — rate limited per domain |
A handful of routes sit outside the /api prefix because they are called by browsers or load balancers: /health, /events/track, /events/consent, /snippet/:tenantId/attrevo.js and the payment webhooks.
Tracking snippet installation
One script tag, on every page, immediately before the closing </body> tag. Replace YOUR_TENANT_ID with the value from Settings → Tracking.
<!-- Paste immediately before </body> on every page -->
<script async src="https://api.attrevo.com/snippet/YOUR_TENANT_ID/attrevo.js"></script>The snippet is deliberately small and self-contained. It:
- Assigns a pseudonymous visitor ID in a first-party cookie (
attrevo_vid, 2 years) and a session ID insessionStorage. - Reads
utm_source,utm_medium,utm_campaign,utm_contentandutm_term, falling back to the referrer. - Sends via
navigator.sendBeaconwhere available, so navigation is never delayed. - Holds every event until consent is granted, then stores the decision in
attrevo_consent.
Nothing is transmitted before consent. If the visitor declines, no touchpoint is sent at all.
Custom event tracking
To track a call-to-action, add data-attrevo="cta" to any element. The snippet listens on document click and walks up the tree, so it works on dynamically rendered markup with no extra wiring.
<!-- Any element with data-attrevo="cta" reports a cta_click -->
<a href="/pricing" data-attrevo="cta">See pricing</a>
<button data-attrevo="cta" id="hero-signup">Start free trial</button>Each click sends a cta_click event carrying the element's trimmed text (up to 120 characters), its id, and its href when present.
Revenue API reference
POST /api/revenue/record — record a payment from a platform Attrevo does not integrate with directly. Authenticated with an API key, not a session.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
source | string | Yes | Max 32 chars, e.g. selar |
amount | number | Yes | Positive, in major units (naira, not kobo) |
reference | string | Yes | Your unique ID; used to de-duplicate |
customerEmail | string | Conditional | At least one of email or phone is required |
customerPhone | string | Conditional | Normalised to +234 before matching |
currency | string | No | 3-letter code, defaults to NGN |
occurredAt | string | No | ISO 8601; defaults to now |
metadata | object | No | Arbitrary key/value pairs, stored as-is |
curl -X POST https://api.attrevo.com/api/revenue/record \
-H "X-Attrevo-API-Key: atv_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"source": "selar",
"amount": 150000,
"currency": "NGN",
"customerEmail": "ada@example.com",
"customerPhone": "+2348012345678",
"reference": "SELAR-TX-91823",
"occurredAt": "2025-07-26T09:15:00.000Z",
"metadata": { "product": "Attribution Masterclass" }
}'Response
{
"recorded": true,
"revenueEvent": {
"id": "clx8f2k9p0001",
"source": "selar",
"amount": 150000,
"currency": "NGN",
"reference": "SELAR-TX-91823",
"matchedLeadId": "clx7a1b2c0003",
"matchMethod": "email",
"occurredAt": "2025-07-26T09:15:00.000Z"
}
}matchMethod is email, phone or null. A null match still records the revenue — it simply cannot be attributed to a channel yet, and will be re-matched if the lead is identified later.
Reading it back
Three read endpoints, all scoped to the calling key's own tenant. There is no tenant id anywhere in a path or a body — you cannot read another workspace's revenue by asking for it.
| Endpoint | Returns |
|---|---|
GET /api/revenue/daily | A point per day. from and to are ISO dates; the window defaults to the last 30 days. |
GET /api/revenue/by-source | Revenue and attribution coverage per source, same date window. |
GET /api/revenue | The events themselves, paginated — page, limit, and filters for from, to, status, source and attribution. |
Webhook integration
Webhooks are the preferred path for supported platforms: revenue arrives in near real time with no polling.
| Platform | Endpoint | Verification |
|---|---|---|
| Paystack | /webhooks/paystack | HMAC SHA512 over the raw body |
| Flutterwave | /api/webhooks/flutterwave | Shared secret header |
| Flutterwave transfers | /api/webhooks/flutterwave-transfer | Shared secret header |
| Selar | /api/webhooks/selar | Shared secret header |
| Monnify | /api/webhooks/monnify | Basic auth, checked against your stored credentials |
| Squad | /api/webhooks/squad | HMAC over the raw body, from x-squad-signature or x-squad-encrypted-body |
| Generic | /api/webhooks/generic | Shared secret + field mapping |
{
"event": "charge.success",
"data": {
"reference": "PSK-8817263",
"amount": 5375000,
"currency": "NGN",
"customer": { "email": "ada@example.com" },
"paid_at": "2025-07-26T09:15:00.000Z"
}
}Signature verification runs against the raw request body before parsing — re-serialising JSON changes the bytes and breaks the HMAC. Deliveries are idempotent on the platform reference, so retries are safe.
The generic endpoint lets you map your own field names onto Attrevo's when configuring the source, for platforms with no native integration.
CSV import
For historical revenue or offline sales, import a CSV under Revenue → Import. Each row is matched using the same email-then-phone logic as the API.
email,phone,amount,currency,reference,occurred_at
ada@example.com,+2348012345678,150000,NGN,INV-001,2025-07-01
chidi@example.com,,89500,NGN,INV-002,2025-07-03
,+2348098765432,240000,NGN,INV-003,2025-07-05emailorphone— at least one per row.amount— major units. Do not include currency symbols or thousands separators.reference— must be unique; duplicates are skipped rather than double-counted.occurred_at—YYYY-MM-DDor full ISO 8601.
Each import is recorded as a batch, so a bad file can be identified and reversed rather than leaving orphaned rows.
API keys
Create keys under Settings → API keys. Keys are prefixed atv_live_ and are shown exactly once at creation — only a hash is stored, so a lost key must be revoked and replaced.
- Send as
X-Attrevo-API-Key, never in a query string. - Each key is scoped to one tenant and carries no user identity.
- Revoking takes effect immediately on the next request.
- Keys belong on a server. Anything in browser JavaScript is public — use the snippet there instead.
Rate limits
Tracking ingestion is rate limited per domain in Redis to protect the platform from misconfigured or malicious pages. Authenticated endpoints are additionally bounded by your plan's monthly event allowance.
Exceeding a limit returns 429. Two distinct cases share that status, distinguished by the body:
- Transport rate limit — retry with exponential backoff.
- Plan limit (
error: "PLAN_LIMIT_REACHED") — retrying will not help until the plan is upgraded or the month rolls over.
Batch where you can: one CSV import is far cheaper than a thousand individual calls.
Enterprise onboarding API
The Enterprise pipeline is trial-first: a prospect applies, reviews what was generated, confirms, and is using the product before anyone on our side has acted. The endpoints below reflect that. Everything here is live.
POST /api/enterprise/apply
Public and unauthenticated. Creates an application at stage INTAKE_SUBMITTED and notifies the team. Rate limited to 5 per IP per hour.
curl -X POST https://api.attrevo.com/api/enterprise/apply \
-H "Content-Type: application/json" \
-d '{
"companyName": "LAPO Microfinance Bank",
"contactName": "Adaeze Okonkwo",
"contactEmail": "adaeze@lapo-nigeria.com",
"contactPhone": "08031234567",
"revenueTracking": "MANUAL_LEDGER",
"volumeTier": "ENT_2",
"channelsToAttribute": "google,meta,whatsapp",
"monthlyMarketingSpend": 9000000,
"registeredDetails": {
"legalName": "LAPO Microfinance Bank Limited",
"rcNumber": "RC402521",
"tin": "32825973-0001",
"registeredAddress": "15 Ihama Road, GRA, Benin City, Edo State",
"billingAddressSameAsRegistered": true,
"signatoryName": "Adaeze Okonkwo",
"signatoryEmail": "adaeze@lapo-nigeria.com",
"financeContactEmail": "finance@lapo-nigeria.com"
}
}'{
"ok": true,
"companyName": "LAPO Microfinance Bank",
"contactEmail": "adaeze@lapo-nigeria.com",
"message": "Thank you — your proposal is on its way to your inbox. Open it to review your plan and start your trial."
}Required: companyName, contactName, contactEmail, contactPhone (Nigerian format), volumeTier — one of ENT_1, ENT_2, ENT_3, ENT_CUSTOM, which sets the price — and revenueTracking, one of BANK_TRANSFER, MANUAL_LEDGER, CUSTOM_SYSTEM, PAYSTACK, FLUTTERWAVE, MONIEPOINT, OTHER. Optional: rcNumber, industry, websiteUrl, monthlyRevenueBand, contactRole, currentTools, channelsToAttribute, monthlyMarketingSpend, notes.
registeredDetails is required and is what a tax invoice is made of. Required inside it: legalName, rcNumber, tin, registeredAddress, billingAddressSameAsRegistered, signatoryName, signatoryEmail, financeContactEmail. Optional: tradingName, signatoryTitle, financeContactName, financeContactPhone, dpoContactEmail. billingAddress is required only when billingAddressSameAsRegistered is false.
rcNumber matches ^(RC)?\s?\d{5,8}$ and tin matches ^\d{8,10}-\d{4}$. These land on a ClientBillingProfile at SUBMITTED — never VERIFIED. An admin still checks them against a CAC certificate before anything is issued.
The response carries no identifier by design. Stage, qualification and ownership are not accepted from the body — sending them changes nothing.
Client review — /api/enterprise/review/:token
Public, resolved entirely from the token. No application id is ever accepted from the request, so there is no id to tamper with.
| Endpoint | Does |
|---|---|
GET /:token | The proposal and pro-forma figures, or a quote-pending state for ENT_CUSTOM. |
PATCH /:token | Client edits — seatCount, confirmedTotal, notes. The system figure is kept alongside theirs, never overwritten. |
POST /:token/confirm-proposal | Step one. Freezes the agreed total onto the proposal ex-VAT, marks it ACCEPTED, moves to CLIENT_CONFIRMED. Does not start the trial. Idempotent. |
PATCH /:token/registered-details | Corrects the particulars captured at intake. Refused once the profile is VERIFIED or the pro-forma is confirmed. |
POST /:token/confirm | Step two. Confirms the pro-forma, starts the 14-day trial, emits the owner invitation. 400 until the proposal is confirmed. Idempotent. |
POST /:token/payment-intent | { method: BANK_TRANSFER | PAYSTACK }. Records intent only — sets AWAITING_CONFIRMATION, confirms nothing. |
POST /:token/pay | Starts a Paystack checkout and returns authorizationUrl. Refused for a quote-only application. |
GET /:token/payment-options | Bank details, reference, and whether a card payment can start. |
All three payment routes return 400 until both confirmations are in, and again once paymentStatus is CONFIRMED — the second guard is what stops a settled client re-reporting a transfer and downgrading their own payment back to AWAITING_CONFIRMATION.
GET /:token carries proposalConfirmedAt and proformaConfirmedAt separately, plus registeredDetails, paymentStatus and — once it exists — taxInvoice, rendered from the invoice's own snapshot rather than live config.
Signed-in trial — /api/enterprise/trial
Session-scoped. The application is resolved from the caller's own tenant; nothing is accepted from the request body.
| Endpoint | Does |
|---|---|
GET /due | What is owed and how to pay it, or { due: null } — returned for a quote-only, already-paid, converted, or non-enterprise tenant. |
GET /entitlements | { enterpriseTrialLocked: boolean }. True while an Enterprise trial has not converted. |
POST /payment-intent | Reports a bank transfer from inside the app. |
POST /pay | Starts a Paystack checkout. 400 when nothing is due. |
While enterpriseTrialLocked is true, report export and bulk CSV import return 403 ENTERPRISE_TRIAL_LOCKED. Reading data on screen is unaffected.
Enterprise card payments
POST /:token/pay and POST /api/enterprise/trial/pay both initialise a Paystack transaction for the frozen total, in kobo, carrying:
metadata.applicationId— the application to settlemetadata.kind— the literal"enterprise_subscription"- no
tenantId— an enterprise payer has no tenant of their own yet
That last point is why the webhook branches on metadata.kind before it tries to resolve a tenant. A charge carrying the marker is settled against its application and is deliberately not written as a RevenueEvent — our subscription fee is not a customer's attributed revenue, and recording it there would inflate every report they run.
{
"event": "charge.success",
"data": {
"reference": "T169159331423881",
"amount": 43000000,
"currency": "NGN",
"customer": { "email": "adaeze@lapo-nigeria.com" },
"metadata": {
"applicationId": "cmt72966h000u15a5pv9vnmok",
"companyName": "LAPO Microfinance Bank",
"kind": "enterprise_subscription"
}
}
}The amount is checked before anything settles. A valid signature proves the message came from Paystack; it does not prove the right sum was paid. The charge is compared against the total the client agreed — their adjusted figure where there is one, not the tier price:
| Charge | Result |
|---|---|
| Equals the agreed total | Settles. Stage moves to PAID, paymentConfirmedBy stays null. |
| Short of it | Refused. Nothing moves; a BILLING_OPS alert carries the shortfall in naira. |
| Another currency | Refused. Pricing is naira-only. |
| No amount, or no agreed total | Refused. |
| Over the agreed total | Settles — the invoice is covered — and is flagged for a decision on the surplus. |
The retry guard runs first, so a redelivered charge for an already confirmed payment is ignored without being re-checked or re-reported. Webhook responses are always 200; the outcome is in the logs and the alert, never in the status code.
PAID is not CONVERTED. Conversion needs payment and qualification, and neither the webhook nor the transfer route touches it.
Profile — /api/profile
Session-scoped and takes no user id anywhere: GET reads it, PATCH updates preferredName and bio, POST /avatar uploads an image (PNG, JPEG or WebP, 2MB), and DELETE /avatar removes it. The avatar comes back as a short-lived signed URL, not a public address.
GET /api/enterprise/billing/:token
Public, authenticated by the one-time token in the path alone. Returns the field spec the client form renders from, plus any values already submitted. No applicationId is accepted anywhere on these routes: one token reaches exactly one profile.
{
"companyName": "LAPO Microfinance Bank",
"status": "REQUESTED",
"expiresAt": "2026-08-23T00:00:00.000Z",
"reviewNote": null,
"groups": [
{
"key": "entity",
"title": "Your registered company details",
"intro": "Use the details on your CAC certificate.",
"fields": [
{
"key": "rcNumber",
"label": "RC number",
"kind": "text",
"required": true,
"pattern": "^(RC)?\\s?\\d{5,8}$",
"patternHint": "e.g. RC402521"
}
]
}
],
"values": { "legalName": "LAPO Microfinance Bank Limited" }
}pattern is a string, not a RegExp — rebuild it with new RegExp(pattern). A RegExp would serialise to {} and the rule would be silently lost.
Refusals carry the message to show the reader: 404 for an unknown link, 400 for an expired one or a profile already confirmed. Rate limited per IP and per (IP, token) — the per-IP window is what blunts token guessing.
POST /api/enterprise/billing/:token
Body is { values: { … } }. Only keys the field spec owns are persisted; anything else in the object is ignored. On success the profile moves to SUBMITTED for admin verification.
Admin endpoints
All under /api/platform-admin/enterprise, behind a Clerk session plus a platform role. Applications, proposals and the implementation checklist are PRODUCT_MANAGER; invoices, payments, contracts and billing verification are BILLING_OPS; SUPER_ADMIN reaches all of it.
| Endpoint | Role | Notes |
|---|---|---|
GET /applications | PM | Paginated; filter by stage |
GET /applications/:id | PM | Proposals, invoices, tasks, transitions, account, profile |
POST /applications/:id/qualify | PM | Optional assignedOwner |
POST /applications/:id/go-live (converts; needs paid AND qualified) | PM | Refused while any implementation task is open |
GET /applications/:id/tasks | PM | Returns gatesCleared and per-task actionable |
PATCH /tasks/:taskId | PM | status, owner, dueDate |
POST /proposals | PM | Omit tierKey to let the recommender choose |
POST /proposals/:id/issue | PM | Renders, stores, then marks SENT — fail-closed, in that order |
POST /proposals/:id/accept | PM | Provisions the 14-day pilot and seeds the checklist |
GET /proposals/:id/download | PM | Signed URL |
POST /invoices | BILLING_OPS | applicationId + proposalId; terms copied from the proposal |
POST /invoices/:id/confirm-payment | BILLING_OPS | Multipart; proof file required |
GET /invoices/:id/download | BILLING_OPS | Signed URL |
POST /contracts/:applicationId/close | BILLING_OPS | Multipart; contract file required |
POST /billing-profiles/:id/request | BILLING_OPS | Issues a new one-time link; invalidates the previous |
POST /billing-profiles/:id/verify | BILLING_OPS | Multipart; evidence file required |
GET /billing-profiles/:id/blockers | BILLING_OPS | Pre-flight before invoicing |
Error shapes
Field-level validation travels as a string array in message — the same channel every validated endpoint uses.
{
"message": [
"Registered company name is required.",
"TIN: e.g. 32825973-0001"
],
"error": "Bad Request",
"statusCode": 400
}Invoicing has its own shape. When the billing profile is not ready, POST /invoices returns INVOICE_BLOCKED with every outstanding reason, so they can be fixed in one pass rather than one refusal at a time.
{
"error": "INVOICE_BLOCKED",
"message": "This invoice cannot be issued yet.",
"blockers": [
"Billing details are SUBMITTED — verify them first.",
"A purchase order number is required but has not been supplied."
]
}Documents and signed URLs
Proposal and invoice PDFs are rendered server-side and stored in private buckets — proposals, invoices, payment-proofs and contracts. What is persisted on the row is the object path, never a URL.
The download endpoints resolve that path from the record and mint a short-lived signed URL. No endpoint accepts a bucket or a path from the caller.
{
"url": "https://<project>.supabase.co/storage/v1/object/sign/proposals/ATV-PROP-2026-0001.pdf?token=...",
"expiresIn": 300,
"fileName": "ATV-PROP-2026-0001.pdf"
}Emitted events
The pipeline emits on an internal event bus; the comms layer subscribes and sends email. Delivery failures never roll back commercial state.
| Event | Emitted when |
|---|---|
enterprise.application.received | A public application is submitted |
enterprise.stage.<STAGE> | Any stage change, e.g. enterprise.stage.PROPOSAL_READY |
enterprise.stage.changed | Every stage change, generically |
enterprise.billing_profile.requested | A one-time link is issued (carries the raw token, once) |
enterprise.billing_profile.submitted | The client submits their details |
enterprise.billing_profile.verified | An admin confirms them against a CAC document |
enterprise.billing_profile.changes_requested | An admin reopens the form with a note |
enterprise.pilot.started | A proposal is accepted and the pilot provisions |
enterprise.payment.recorded | A payment is reconciled (full or partial) |
enterprise.contract.closed | An executed contract is filed |
enterprise.account.activated | Both gates clear and data import unlocks |
enterprise.implementation.stalled | Daily sweep flags an activated account with open tasks |
enterprise.pilots.swept | Daily sweep suspends or expires lapsed pilots |
Error codes
Errors use standard HTTP status codes with a consistent JSON body.
{
"statusCode": 401,
"error": "Unauthorized",
"message": "Missing X-Attrevo-API-Key header"
}| Status | Meaning | What to do |
|---|---|---|
| 400 | Validation failed | Read the message — it names the offending field. |
| 401 | Missing or invalid credential | Check the header name and that the key is not revoked. |
| 403 | Authenticated but not permitted | Often no tenant yet — call POST /api/tenants/bootstrap. |
| 404 | Not found, or wrong method on a valid path | Confirm the verb: a GET on a POST-only route also returns 404. |
| 429 | Rate or plan limit | See the section above. |
| 500 | Server error | Safe to retry idempotent calls; contact support if it persists. |