Developers

The Clerra API

Read a firm’s clients, CRA filing deadlines, invoices, jobs and pipeline. Add leads. Hear about filings filed and invoices paid the moment they happen. Every key belongs to one firm and sees only that firm.

GET /api/v1/me
curl https://app.clerra.ca/api/v1/me \
  -H "Authorization: Bearer clerra_…"

{
  "data": {
    "firm": {
      "id": "3f1e9a2b-…",
      "name": "Maple & Pine CPA"
    },
    "key": {
      "name": "Our CRM",
      "scopes": ["clients:read", "filings:read"],
      "expires_at": "2027-10-14T13:00:00.000Z"
    }
  }
}

Start here

Quick start

  1. 1

    Turn it on

    A firm admin opens Settings → API in Clerra and switches API access on.

  2. 2

    Make a key

    Choose only what it needs, then copy the key — it's shown once.

  3. 3

    Call it

    Send the key in the Authorization header. Start with GET /me.

List the firm's filings due, oldest first
curl "https://app.clerra.ca/api/v1/filings?status=upcoming&limit=2" \
  -H "Authorization: Bearer $CLERRA_KEY"

Keys

Keys look like clerra_ followed by two parts: a public id and a secret. Send the whole key as a bearer token: Authorization: Bearer clerra_…. A key in the URL is refused — URLs end up in logs.

  • A key belongs to one firm. Which firm comes from the key itself, never from anything in the request.
  • Clerra keeps only a one-way hash of the secret. Lost it? Revoke it and make another.
  • Keys can expire (30 days, 90 days, a year) or not. Revoking one stops it on the next request.
  • Only someone whose role covers every client can make a key, and a key can’t do anything its maker’s role can’t.

Connect an app (OAuth)

Building something many firms will use? Instead of asking each firm to paste a key, send them through OAuth 2.0: they sign in to Clerra, see exactly what your app is asking for, and approve. Your app gets tokens for that one firm. Apps are registered by Clerra — write to support@clerra.ca with your app’s name, redirect URL and the scopes it needs.

  1. Send the person to https://app.clerra.ca/oauth/authorize with response_type=code, client_id, redirect_uri (exactly as registered), scope (space-separated), state, and PKCE: code_challenge with code_challenge_method=S256 (required for apps without a secret, recommended for all).
  2. Someone in the firm with the “API keys” permission approves. They come back to your redirect_uri with code and your state — or error=access_denied.
  3. Exchange the code (within 10 minutes, once) at POST https://app.clerra.ca/oauth/token.
  4. Call the API with the access token exactly like a key: Authorization: Bearer clerra_at_….
Exchange the code
curl -X POST https://app.clerra.ca/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=$CODE \
  -d redirect_uri=https://yourapp.example/callback \
  -d code_verifier=$VERIFIER

{
  "access_token": "clerra_at_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "clerra_rt_…",
  "scope": "clients:read filings:read"
}
  • Access tokens last an hour. Get a new pair with grant_type=refresh_token; each refresh token works once (it’s replaced every time) and expires after 180 days unused. Re-using a replaced refresh token disconnects the app.
  • Client authentication: HTTP Basic (client_id:client_secret) or client_id and client_secret in the form body. Form-encoded bodies, JSON accepted.
  • POST /oauth/revoke with token=… disconnects your app from that firm (RFC 7009). The firm can also disconnect it any time in Settings → API.
  • A connection follows the same rules as a key — paid plan, API access on — and stops if the person who approved it leaves the firm.

Who can use it

The API is part of paid Clerra plans. It’s checked on every single request, so these take effect immediately:

Free trial

403 api_trial

No API access — it starts with a paid plan.

Switched off

403 api_disabled

The firm can turn API access off in Settings; every key stops. Turning it back on restores them.

Payment failed

403 api_unpaid

Paused until the account is paid, then it resumes by itself.

Subscription ended

403 api_inactive

No access.

Scopes

Each key carries only the scopes it was given. An endpoint needing a scope the key lacks answers 403 insufficient_scope.

clients:readRead clients. Client names, contact details, entity type, province and status.
filings:readRead filings. Every client's filing deadlines and their status.
invoices:readRead invoices. Invoices, totals and what's still owing (no payment links).
jobs:readRead jobs. Jobs, their status, due dates and who's assigned.
leads:readRead leads. The sales pipeline.
leads:writeAdd leads. Create new leads in the sales pipeline.
events:readRead activity. What happened, as it happens — limited to the kinds of record the key can read.
webhooks:manageManage webhooks. Subscribe a URL (like a Zapier trigger) to activity as it happens — only kinds of record it can read.

Requests & paging

  • JSON in and out. Successful responses wrap the result in data; lists add next_cursor.
  • Lists come oldest first by id. Pass cursor = the last id you got to continue; next_cursor is null on the last page. Add updated_since to sync only what changed.
  • Ids are UUIDs. Times are ISO 8601 in UTC; dates are YYYY-MM-DD. Money is integer cents, CAD unless the invoice says otherwise.
  • Every response has an X-Request-Id — quote it if you contact us. Nothing is cached (Cache-Control: no-store).
  • Sample clients a firm uses to try Clerra are never returned.
Paging
GET /api/v1/clients?limit=100
→ { "data": [ … 100 clients … ], "next_cursor": "c0ffee00-…" }

GET /api/v1/clients?limit=100&cursor=c0ffee00-…
→ { "data": [ … 37 clients … ], "next_cursor": null }

Rate limits

120 requests a minute per key. Each response says where you stand in X-RateLimit-Limit and X-RateLimit-Remaining; over the limit you get 429 with Retry-After (seconds). Separately, 20 failed key attempts in a minute from one address blocks that address for the rest of the minute.

Errors

HTTP/1.1 403 Forbidden
{ "error": { "code": "insufficient_scope", "message": "This key doesn't have the “invoices:read” scope." } }
400bad_limit · bad_cursor · bad_type · …A query parameter is wrong; the message says which.
400key_in_urlThe key was put in the URL. Send it only in the Authorization header.
401unauthorizedNo key, or a wrong, revoked or expired one (deliberately the same answer for all).
403insufficient_scopeThe key doesn't have the scope this endpoint needs.
403api_disabledThe firm has switched API access off.
403api_trialThe firm is on a free trial — the API starts with a paid plan.
403api_unpaidThe firm's latest payment failed; access resumes when it's paid.
403api_inactiveThe firm's subscription isn't active.
404not_foundNo such record in this firm.
409too_many_hooksThe key or firm has reached its webhook limit.
413too_largeThe body is too big.
415json_requiredSend JSON with Content-Type: application/json.
422invalid · bad_event · bad_urlThe body didn't validate; the message lists each problem.
429rate_limited · too_many_failuresSlow down — Retry-After says how many seconds.
500server_errorOur fault. Retry; quote X-Request-Id if it persists.

Endpoints

Base URL https://app.clerra.ca/api/v1

GET

/me

any scope

Which firm the key belongs to and what it can do. Works with any scope — use it to test a connection.

Returns the firm and the key.

GET

/clients

scope clients:read

Every client, oldest first.

Parameters

limitinteger1–100, default 50.
cursorstringThe id of the last record you received; continues after it.
updated_sincestringOnly records changed at or after this ISO 8601 time — for syncing.
searchstringPart of the name, legal name or email (2+ letters or numbers).
emailstringExact email (not case-sensitive).

Returns a list of Client objects.

GET

/clients/{id}

scope clients:read

One client by id.

Parameters

idstringIn the path. Client id.

Returns one Client.

GET

/filings

scope filings:read

Every filing deadline across the firm's clients.

Parameters

limitinteger1–100, default 50.
cursorstringThe id of the last record you received; continues after it.
updated_sincestringOnly records changed at or after this ISO 8601 time — for syncing.
client_idstringOnly this client's.
statusstringupcoming, in_progress, waiting_on_client, filed, skipped.

Returns a list of Filing objects.

GET

/invoices

scope invoices:read

Invoices with totals and what's still owing.

Parameters

limitinteger1–100, default 50.
cursorstringThe id of the last record you received; continues after it.
updated_sincestringOnly records changed at or after this ISO 8601 time — for syncing.
client_idstringOnly this client's.

Returns a list of Invoice objects.

GET

/jobs

scope jobs:read

Client jobs (not internal, firm-only work).

Parameters

limitinteger1–100, default 50.
cursorstringThe id of the last record you received; continues after it.
updated_sincestringOnly records changed at or after this ISO 8601 time — for syncing.
client_idstringOnly this client's.

Returns a list of Job objects.

GET

/leads

scope leads:read

The sales pipeline.

Parameters

limitinteger1–100, default 50.
cursorstringThe id of the last record you received; continues after it.
updated_sincestringOnly records changed at or after this ISO 8601 time — for syncing.

Returns a list of Lead objects.

POST

/leads

scope leads:write

Adds a prospect to the pipeline (source “API” unless you give one). Unknown fields are rejected, not ignored.

Body

namestringBusiness or person. Required.
contact_namestring | nullWho to talk to.
emailemail | nullEmail.
phonestring | nullPhone.
websitestring | nullWebsite.
entity_typestring | nullKind of taxpayer.
stagestring | nullPipeline stage (default new).new · contacted · meeting · proposal · won · lost
sourcestring | nullWhere it came from.
estimated_valuenumber | nullExpected yearly fees, in dollars.
notesstring | nullNotes.
Example
curl -X POST https://app.clerra.ca/api/v1/leads \
  -H "Authorization: Bearer $CLERRA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Northwind Bakery Ltd.","contact_name":"Amira Haddad","email":"amira@northwind.example","stage":"new","source":"Website form"}'

Returns one Lead. 201 Created.

GET

/events

scope events:read

What happened, oldest first — poll with ?after. Only kinds of record the key can read (a key with Read invoices sees invoice.* and payment.*).

Parameters

afterstringThe id of the last event you received.
limitinteger1–100, default 50.
typestringComma-separated event types, e.g. filing.filed,invoice.paid.
orderstringasc (default) or desc for the newest first (no paging).
includestringrecord — add each event's record as it is now.

Returns a list of Event objects.

GET

/hooks

scope webhooks:manage

The hooks this key made.

Returns a list of Hook objects.

POST

/hooks

scope webhooks:manage

Subscribe an https URL to event types the key can read. The response includes the signing secret — shown this once.

Body

urlurihttps URL on a public host. Required.
eventsstring[]Event types. Required.
descriptionstring | nullNote.
Example
curl -X POST https://app.clerra.ca/api/v1/hooks \
  -H "Authorization: Bearer $CLERRA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/clerra","events":["filing.filed","invoice.paid"],"description":"Our CRM"}'

Returns one Hook. 201 Created, with secret.

DELETE

/hooks/{id}

scope webhooks:manage

Stop sending to a hook this key made.

Parameters

idstringIn the path. Hook id.

Returns { "id": "…", "deleted": true }

Objects

Client

A person or business the firm works for.

iduuidUnique, never reused.
namestringDisplay name.
legal_namestring | nullLegal name, when it differs.
entity_typestringWhat kind of taxpayer — it decides which CRA deadlines apply. “custom” is a firm-defined type that generates no deadlines.individual · corporation · cooperative · partnership · limited_partnership · trust · non_profit · charity · custom
statusstringWhere the client stands with the firm.
provincestring | nullTwo-letter province or territory code.
emailemail | nullMain email.
phonestring | nullMain phone.
client_codestring | nullThe firm's own reference.
fiscal_year_endobject | null{ month, day } — for businesses and trusts.
office_iduuid | nullThe office the client belongs to.
account_manager_iduuid | nullThe staff member who owns the relationship.
needs_detailsbooleanTrue when Clerra can't yet work out the client's deadlines (a detail is missing) — it never guesses.
created_atdate-timeWhen the client was added.
updated_atdate-timeLast change.

Filing

One deadline for one client — a return, remittance or slip — computed from CRA and provincial rules.

iduuidUnique, never reused.
client_iduuidThe client it's for.
namestringWhat's due, e.g. “T1 return”.
rule_codestring | nullThe rule that produced it.
categorystring | nullFamily of deadline.income_tax · gst_hst · payroll · payroll_run · information_returns · provincial · registry
period_startdate | nullStart of the period it covers.
period_enddate | nullEnd of the period it covers.
due_datedateWhen it's due.
statusstringProgress.upcoming · in_progress · waiting_on_client · filed · skipped
filed_atdate-time | nullWhen it was marked filed.
created_atdate-timeWhen it was created.
updated_atdate-timeLast change.

Invoice

A bill to a client. Amounts are integers in cents; no payment links are exposed.

iduuidUnique, never reused.
client_iduuidThe client billed.
numberstringInvoice number.
statusstringdraft, sent, paid, void and so on.
issue_datedate | nullIssue date.
due_datedate | nullDue date.
currencystringISO currency code.
subtotal_centsintegerBefore tax, in cents.
tax_centsintegerAll sales tax (GST/HST/PST/QST), in cents.
total_centsintegerTotal, in cents.
credited_centsintegerCredit notes applied, in cents.
paid_centsintegerPaid so far, in cents.
balance_centsintegerStill owing (0 for a void invoice), in cents.
paid_ondate | nullWhen it was paid in full.
created_atdate-timeWhen it was created.
updated_atdate-timeLast change.

Job

A piece of work for a client — a T1, a year-end, a bookkeeping month.

iduuidUnique, never reused.
client_iduuidThe client it's for.
namestringJob name.
periodstring | nullPeriod label, e.g. “2025”.
statusstringProgress.
due_datedate | nullDue date.
assigned_touuid | nullStaff member doing it.
reviewer_iduuid | nullStaff member reviewing it.
template_iduuid | nullTemplate it came from.
completed_atdate-time | nullWhen it was finished.
created_atdate-timeWhen it was created.
updated_atdate-timeLast change.

Lead

A prospect in the firm's sales pipeline.

iduuidUnique, never reused.
namestringBusiness or person.
contact_namestring | nullWho to talk to.
emailemail | nullEmail.
phonestring | nullPhone.
entity_typestring | nullKind of taxpayer, if known.
stagestringPipeline stage.new · contacted · meeting · proposal · won · lost
sourcestring | nullWhere it came from (“API” when created through it).
estimated_valuenumber | nullExpected yearly fees, in dollars.
expected_closedate | nullWhen the firm expects to win it.
owner_iduuid | nullStaff member who owns it.
websitestring | nullWebsite.
converted_client_iduuid | nullThe client it became, once won.
created_atdate-timeWhen it was added.
updated_atdate-timeLast change.

Event

Something that happened — the same shape in GET /events and in every webhook.

iduuidUnique, never reused.
objectstringAlways “event”.
api_versionstringThe envelope version (a date).
typestringWhat happened, e.g. filing.filed.
occurred_atdate-timeWhen.
firm_iduuidThe firm.
client_iduuid | nullThe client it's about, if any.
subject_typestring | nullKind of record: client, filing, invoice, job, step, lead.
subject_iduuid | nullThat record's id.
dataobjectIds, statuses, amounts and dates about the change — never names or messages.
recordobject | nullThe record as it is now (as the API returns it). In webhooks always; in GET /events with ?include=record. Null if it's gone.

Hook

A URL subscribed (through the API) to kinds of event.

iduuidUnique, never reused.
urluriWhere Clerra POSTs.
descriptionstring | nullNote.
eventsstring[]Event types it receives.
enabledbooleanFalse once switched off (by the firm, a 410 from the receiver, or 20 failures in a row).
disabled_reasonstring | nullWhy it was switched off.
created_atdate-timeWhen it was made.

Events

What a key can follow, through GET /events or webhooks. data holds only ids, statuses, amounts and dates — never a name or a message. For the details, read the record (webhooks include it).

Clients

client.createdA client is addedsource, status, entity_type
client.status_changedA client's status changesfrom, to

Filings

filing.status_changedA filing's status changesfrom, to, rule_code, due_date
filing.filedA filing is marked filedrule_code, due_date, filed_source
filing.overdueA filing becomes overduerule_code, due_date

Invoices & payments

invoice.createdAn invoice is createdsource, status, total_cents
invoice.sentAn invoice is senttotal_cents, due_date
invoice.paidAn invoice is paid in fulltotal_cents, source
payment.receivedA payment is receivedamount_cents, source, paid_in_full, paid_on
invoice.overdueAn invoice becomes overduedue_date, balance_cents
invoice.payment_failedA card payment fails

Jobs

job.createdA job is createdsource, template_id, obligation_id
job.status_changedA job's status changesfrom, to
job.step_completedA job step is completedjob_id, auto

Sales pipeline

lead.createdA lead is addedsource, stage
lead.stage_changedA lead moves stagefrom, to

Webhooks

Instead of polling, have Clerra POST to your URL as things happen. Add one in Settings → API → Webhooks, or through the API with POST /hooks (how Zapier-style integrations subscribe). The URL must be https on a public host.

POST to your URL
{
  "id": "0b7c2f9e-5a1d-4f63-9a0e-2c8d1e4b7f10",
  "object": "event",
  "api_version": "2026-10-14",
  "type": "filing.filed",
  "occurred_at": "2027-04-28T15:42:07.113Z",
  "firm_id": "3f1e9a2b-7c4d-4e8f-a6b5-1d2c3e4f5a6b",
  "client_id": "8a6d4c2e-1b3f-4a5c-9e7d-6f8a0b1c2d3e",
  "subject_type": "filing",
  "subject_id": "5e4d3c2b-1a0f-4e9d-8c7b-6a5f4e3d2c1b",
  "data": {
    "rule_code": "t1_filing",
    "due_date": "2027-04-30",
    "filed_source": "manual"
  },
  "record": {
    "id": "5e4d3c2b-1a0f-4e9d-8c7b-6a5f4e3d2c1b",
    "client_id": "8a6d4c2e-1b3f-4a5c-9e7d-6f8a0b1c2d3e",
    "name": "T1 return",
    "rule_code": "t1_filing",
    "category": "income_tax",
    "period_start": "2026-01-01",
    "period_end": "2026-12-31",
    "due_date": "2027-04-30",
    "status": "filed",
    "filed_at": "2027-04-28T15:42:07.000Z",
    "created_at": "2026-10-14T09:00:00.000Z",
    "updated_at": "2027-04-28T15:42:07.000Z"
  }
}

Headers

Clerra-Signaturestringt=<unix seconds>,v1=<hex HMAC-SHA256 of “t.body” with your signing secret>.
Clerra-Event-IdstringThe event id — the same on every retry. Use it to ignore duplicates.
Clerra-Event-Typestringe.g. filing.filed.
Clerra-Delivery-IdstringThis delivery.

Check the signature

Always verify before trusting a message: recompute the HMAC over the raw body and refuse anything older than five minutes.

Node.js
import crypto from "node:crypto";

// Use the raw request body — not a re-serialised object.
export function verifyClerra(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // replayed or stale
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
Python
import hashlib, hmac, time

def verify_clerra(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", 0))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Delivery

  • Answer with any 2xx within 10 seconds. Do slow work after you’ve answered.
  • Anything else — or no answer — is retried up to six more times, waiting longer each time. Redirects aren’t followed.
  • Delivery is at least once and not strictly in order. Use Clerra-Event-Id to skip repeats and occurred_at to order.
  • record is the record as it is when the message is sent, which can be newer than the event.
  • Answer 410 Gone and Clerra stops sending to that URL. After 20 failed deliveries in a row it’s switched off and the firm is told.
  • Webhooks follow the same rules as keys: nothing is sent while the firm’s API access is off or unpaid, and a hook made with a key stops when the key is revoked.

Your clients’ data

Clerra stores firm data in Canada. Anything you pull through the API or receive by webhook is then wherever your system keeps it — check that it meets your firm’s obligations before connecting it. Ask only for the scopes you need, keep keys and signing secrets in a secrets manager, and revoke anything you stop using.

Questions or a scope you’re missing? Write to support@clerra.ca.

Developers — Clerra API · Clerra