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:

ModeUsed byCredential
Session (Clerk JWT)Dashboard requests from the browserAuthorization: Bearer <jwt>
API keyServer-to-server, e.g. the revenue APIX-Attrevo-API-Key: atv_live_…
PublicTracking ingestion, consent, snippetNone — 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.

Was this helpful?

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.

index.html
<!-- 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 in sessionStorage.
  • Reads utm_source, utm_medium, utm_campaign, utm_content and utm_term, falling back to the referrer.
  • Sends via navigator.sendBeacon where 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.

Was this helpful?

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.

cta.html
<!-- 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.

Was this helpful?

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

FieldTypeRequiredNotes
sourcestringYesMax 32 chars, e.g. selar
amountnumberYesPositive, in major units (naira, not kobo)
referencestringYesYour unique ID; used to de-duplicate
customerEmailstringConditionalAt least one of email or phone is required
customerPhonestringConditionalNormalised to +234 before matching
currencystringNo3-letter code, defaults to NGN
occurredAtstringNoISO 8601; defaults to now
metadataobjectNoArbitrary key/value pairs, stored as-is
request
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

200 OK
{
  "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.

EndpointReturns
GET /api/revenue/dailyA point per day. from and to are ISO dates; the window defaults to the last 30 days.
GET /api/revenue/by-sourceRevenue and attribution coverage per source, same date window.
GET /api/revenueThe events themselves, paginated — page, limit, and filters for from, to, status, source and attribution.
Was this helpful?

Webhook integration

Webhooks are the preferred path for supported platforms: revenue arrives in near real time with no polling.

PlatformEndpointVerification
Paystack/webhooks/paystackHMAC SHA512 over the raw body
Flutterwave/api/webhooks/flutterwaveShared secret header
Flutterwave transfers/api/webhooks/flutterwave-transferShared secret header
Selar/api/webhooks/selarShared secret header
Monnify/api/webhooks/monnifyBasic auth, checked against your stored credentials
Squad/api/webhooks/squadHMAC over the raw body, from x-squad-signature or x-squad-encrypted-body
Generic/api/webhooks/genericShared secret + field mapping
paystack charge.success
{
  "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.

Was this helpful?

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.

revenue.csv
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-05
  • email or phone — 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-DD or full ISO 8601.

Each import is recorded as a batch, so a bad file can be identified and reversed rather than leaving orphaned rows.

Was this helpful?

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.
Was this helpful?

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.

Was this helpful?

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.

apply
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"
    }
  }'
201 Created
{
  "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.

EndpointDoes
GET /:tokenThe proposal and pro-forma figures, or a quote-pending state for ENT_CUSTOM.
PATCH /:tokenClient edits — seatCount, confirmedTotal, notes. The system figure is kept alongside theirs, never overwritten.
POST /:token/confirm-proposalStep 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-detailsCorrects the particulars captured at intake. Refused once the profile is VERIFIED or the pro-forma is confirmed.
POST /:token/confirmStep 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/payStarts a Paystack checkout and returns authorizationUrl. Refused for a quote-only application.
GET /:token/payment-optionsBank 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.

EndpointDoes
GET /dueWhat 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-intentReports a bank transfer from inside the app.
POST /payStarts 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 settle
  • metadata.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.

charge.success
{
  "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:

ChargeResult
Equals the agreed totalSettles. Stage moves to PAID, paymentConfirmedBy stays null.
Short of itRefused. Nothing moves; a BILLING_OPS alert carries the shortfall in naira.
Another currencyRefused. Pricing is naira-only.
No amount, or no agreed totalRefused.
Over the agreed totalSettles — 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.

200 OK
{
  "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.

EndpointRoleNotes
GET /applicationsPMPaginated; filter by stage
GET /applications/:idPMProposals, invoices, tasks, transitions, account, profile
POST /applications/:id/qualifyPMOptional assignedOwner
POST /applications/:id/go-live (converts; needs paid AND qualified)PMRefused while any implementation task is open
GET /applications/:id/tasksPMReturns gatesCleared and per-task actionable
PATCH /tasks/:taskIdPMstatus, owner, dueDate
POST /proposalsPMOmit tierKey to let the recommender choose
POST /proposals/:id/issuePMRenders, stores, then marks SENT — fail-closed, in that order
POST /proposals/:id/acceptPMProvisions the 14-day pilot and seeds the checklist
GET /proposals/:id/downloadPMSigned URL
POST /invoicesBILLING_OPSapplicationId + proposalId; terms copied from the proposal
POST /invoices/:id/confirm-paymentBILLING_OPSMultipart; proof file required
GET /invoices/:id/downloadBILLING_OPSSigned URL
POST /contracts/:applicationId/closeBILLING_OPSMultipart; contract file required
POST /billing-profiles/:id/requestBILLING_OPSIssues a new one-time link; invalidates the previous
POST /billing-profiles/:id/verifyBILLING_OPSMultipart; evidence file required
GET /billing-profiles/:id/blockersBILLING_OPSPre-flight before invoicing

Error shapes

Field-level validation travels as a string array in message — the same channel every validated endpoint uses.

400 Bad Request
{
  "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.

400 Bad Request
{
  "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.

200 OK
{
  "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.

EventEmitted when
enterprise.application.receivedA public application is submitted
enterprise.stage.<STAGE>Any stage change, e.g. enterprise.stage.PROPOSAL_READY
enterprise.stage.changedEvery stage change, generically
enterprise.billing_profile.requestedA one-time link is issued (carries the raw token, once)
enterprise.billing_profile.submittedThe client submits their details
enterprise.billing_profile.verifiedAn admin confirms them against a CAC document
enterprise.billing_profile.changes_requestedAn admin reopens the form with a note
enterprise.pilot.startedA proposal is accepted and the pilot provisions
enterprise.payment.recordedA payment is reconciled (full or partial)
enterprise.contract.closedAn executed contract is filed
enterprise.account.activatedBoth gates clear and data import unlocks
enterprise.implementation.stalledDaily sweep flags an activated account with open tasks
enterprise.pilots.sweptDaily sweep suspends or expires lapsed pilots
Was this helpful?

Error codes

Errors use standard HTTP status codes with a consistent JSON body.

401
{
  "statusCode": 401,
  "error": "Unauthorized",
  "message": "Missing X-Attrevo-API-Key header"
}
StatusMeaningWhat to do
400Validation failedRead the message — it names the offending field.
401Missing or invalid credentialCheck the header name and that the key is not revoked.
403Authenticated but not permittedOften no tenant yet — call POST /api/tenants/bootstrap.
404Not found, or wrong method on a valid pathConfirm the verb: a GET on a POST-only route also returns 404.
429Rate or plan limitSee the section above.
500Server errorSafe to retry idempotent calls; contact support if it persists.
Was this helpful?