Start here
Quick start
- 1
Turn it on
A firm admin opens Settings → API in Clerra and switches API access on.
- 2
Make a key
Choose only what it needs, then copy the key — it's shown once.
- 3
Call it
Send the key in the Authorization header. Start with GET /me.
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.
- Send the person to
https://app.clerra.ca/oauth/authorizewithresponse_type=code,client_id,redirect_uri(exactly as registered),scope(space-separated),state, and PKCE:code_challengewithcode_challenge_method=S256(required for apps without a secret, recommended for all). - Someone in the firm with the “API keys” permission approves. They come back to your
redirect_uriwithcodeand yourstate— orerror=access_denied. - Exchange the code (within 10 minutes, once) at
POST https://app.clerra.ca/oauth/token. - Call the API with the access token exactly like a key:
Authorization: Bearer clerra_at_….
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) orclient_idandclient_secretin the form body. Form-encoded bodies, JSON accepted. POST /oauth/revokewithtoken=…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_trialNo API access — it starts with a paid plan.
Switched off
403 api_disabledThe firm can turn API access off in Settings; every key stops. Turning it back on restores them.
Payment failed
403 api_unpaidPaused until the account is paid, then it resumes by itself.
Subscription ended
403 api_inactiveNo access.
Scopes
Each key carries only the scopes it was given. An endpoint needing a scope the key lacks answers 403 insufficient_scope.
| clients:read | Read clients. Client names, contact details, entity type, province and status. |
| filings:read | Read filings. Every client's filing deadlines and their status. |
| invoices:read | Read invoices. Invoices, totals and what's still owing (no payment links). |
| jobs:read | Read jobs. Jobs, their status, due dates and who's assigned. |
| leads:read | Read leads. The sales pipeline. |
| leads:write | Add leads. Create new leads in the sales pipeline. |
| events:read | Read activity. What happened, as it happens — limited to the kinds of record the key can read. |
| webhooks:manage | Manage 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 addnext_cursor. - Lists come oldest first by id. Pass
cursor= the last id you got to continue;next_cursoris null on the last page. Addupdated_sinceto 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.
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." } }| 400 | bad_limit · bad_cursor · bad_type · … | A query parameter is wrong; the message says which. |
| 400 | key_in_url | The key was put in the URL. Send it only in the Authorization header. |
| 401 | unauthorized | No key, or a wrong, revoked or expired one (deliberately the same answer for all). |
| 403 | insufficient_scope | The key doesn't have the scope this endpoint needs. |
| 403 | api_disabled | The firm has switched API access off. |
| 403 | api_trial | The firm is on a free trial — the API starts with a paid plan. |
| 403 | api_unpaid | The firm's latest payment failed; access resumes when it's paid. |
| 403 | api_inactive | The firm's subscription isn't active. |
| 404 | not_found | No such record in this firm. |
| 409 | too_many_hooks | The key or firm has reached its webhook limit. |
| 413 | too_large | The body is too big. |
| 415 | json_required | Send JSON with Content-Type: application/json. |
| 422 | invalid · bad_event · bad_url | The body didn't validate; the message lists each problem. |
| 429 | rate_limited · too_many_failures | Slow down — Retry-After says how many seconds. |
| 500 | server_error | Our fault. Retry; quote X-Request-Id if it persists. |
Endpoints
Base URL https://app.clerra.ca/api/v1
- GET/me
- GET/clients
- GET/clients/{id}
- GET/filings
- GET/invoices
- GET/jobs
- GET/leads
- POST/leads
- GET/events
- GET/hooks
- POST/hooks
- DELETE/hooks/{id}
/me
any scopeWhich 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.
/clients
scopeclients:readEvery client, oldest first.
Parameters
| limit | integer | 1–100, default 50. |
| cursor | string | The id of the last record you received; continues after it. |
| updated_since | string | Only records changed at or after this ISO 8601 time — for syncing. |
| search | string | Part of the name, legal name or email (2+ letters or numbers). |
| string | Exact email (not case-sensitive). |
Returns a list of Client objects.
/clients/{id}
scopeclients:readOne client by id.
Parameters
| id | string | In the path. Client id. |
Returns one Client.
/filings
scopefilings:readEvery filing deadline across the firm's clients.
Parameters
| limit | integer | 1–100, default 50. |
| cursor | string | The id of the last record you received; continues after it. |
| updated_since | string | Only records changed at or after this ISO 8601 time — for syncing. |
| client_id | string | Only this client's. |
| status | string | upcoming, in_progress, waiting_on_client, filed, skipped. |
Returns a list of Filing objects.
/invoices
scopeinvoices:readInvoices with totals and what's still owing.
Parameters
| limit | integer | 1–100, default 50. |
| cursor | string | The id of the last record you received; continues after it. |
| updated_since | string | Only records changed at or after this ISO 8601 time — for syncing. |
| client_id | string | Only this client's. |
Returns a list of Invoice objects.
/jobs
scopejobs:readClient jobs (not internal, firm-only work).
Parameters
| limit | integer | 1–100, default 50. |
| cursor | string | The id of the last record you received; continues after it. |
| updated_since | string | Only records changed at or after this ISO 8601 time — for syncing. |
| client_id | string | Only this client's. |
Returns a list of Job objects.
/leads
scopeleads:readThe sales pipeline.
Parameters
| limit | integer | 1–100, default 50. |
| cursor | string | The id of the last record you received; continues after it. |
| updated_since | string | Only records changed at or after this ISO 8601 time — for syncing. |
Returns a list of Lead objects.
/leads
scopeleads:writeAdds a prospect to the pipeline (source “API” unless you give one). Unknown fields are rejected, not ignored.
Body
| name | string | Business or person. Required. |
| contact_name | string | null | Who to talk to. |
| email | null | Email. | |
| phone | string | null | Phone. |
| website | string | null | Website. |
| entity_type | string | null | Kind of taxpayer. |
| stage | string | null | Pipeline stage (default new).new · contacted · meeting · proposal · won · lost |
| source | string | null | Where it came from. |
| estimated_value | number | null | Expected yearly fees, in dollars. |
| notes | string | null | Notes. |
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.
/events
scopeevents:readWhat happened, oldest first — poll with ?after. Only kinds of record the key can read (a key with Read invoices sees invoice.* and payment.*).
Parameters
| after | string | The id of the last event you received. |
| limit | integer | 1–100, default 50. |
| type | string | Comma-separated event types, e.g. filing.filed,invoice.paid. |
| order | string | asc (default) or desc for the newest first (no paging). |
| include | string | record — add each event's record as it is now. |
Returns a list of Event objects.
/hooks
scopewebhooks:manageThe hooks this key made.
Returns a list of Hook objects.
/hooks
scopewebhooks:manageSubscribe an https URL to event types the key can read. The response includes the signing secret — shown this once.
Body
| url | uri | https URL on a public host. Required. |
| events | string[] | Event types. Required. |
| description | string | null | Note. |
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.
/hooks/{id}
scopewebhooks:manageStop sending to a hook this key made.
Parameters
| id | string | In the path. Hook id. |
Returns { "id": "…", "deleted": true }
Objects
Client
A person or business the firm works for.
| id | uuid | Unique, never reused. |
| name | string | Display name. |
| legal_name | string | null | Legal name, when it differs. |
| entity_type | string | What 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 |
| status | string | Where the client stands with the firm. |
| province | string | null | Two-letter province or territory code. |
| email | null | Main email. | |
| phone | string | null | Main phone. |
| client_code | string | null | The firm's own reference. |
| fiscal_year_end | object | null | { month, day } — for businesses and trusts. |
| office_id | uuid | null | The office the client belongs to. |
| account_manager_id | uuid | null | The staff member who owns the relationship. |
| needs_details | boolean | True when Clerra can't yet work out the client's deadlines (a detail is missing) — it never guesses. |
| created_at | date-time | When the client was added. |
| updated_at | date-time | Last change. |
Filing
One deadline for one client — a return, remittance or slip — computed from CRA and provincial rules.
| id | uuid | Unique, never reused. |
| client_id | uuid | The client it's for. |
| name | string | What's due, e.g. “T1 return”. |
| rule_code | string | null | The rule that produced it. |
| category | string | null | Family of deadline.income_tax · gst_hst · payroll · payroll_run · information_returns · provincial · registry |
| period_start | date | null | Start of the period it covers. |
| period_end | date | null | End of the period it covers. |
| due_date | date | When it's due. |
| status | string | Progress.upcoming · in_progress · waiting_on_client · filed · skipped |
| filed_at | date-time | null | When it was marked filed. |
| created_at | date-time | When it was created. |
| updated_at | date-time | Last change. |
Invoice
A bill to a client. Amounts are integers in cents; no payment links are exposed.
| id | uuid | Unique, never reused. |
| client_id | uuid | The client billed. |
| number | string | Invoice number. |
| status | string | draft, sent, paid, void and so on. |
| issue_date | date | null | Issue date. |
| due_date | date | null | Due date. |
| currency | string | ISO currency code. |
| subtotal_cents | integer | Before tax, in cents. |
| tax_cents | integer | All sales tax (GST/HST/PST/QST), in cents. |
| total_cents | integer | Total, in cents. |
| credited_cents | integer | Credit notes applied, in cents. |
| paid_cents | integer | Paid so far, in cents. |
| balance_cents | integer | Still owing (0 for a void invoice), in cents. |
| paid_on | date | null | When it was paid in full. |
| created_at | date-time | When it was created. |
| updated_at | date-time | Last change. |
Job
A piece of work for a client — a T1, a year-end, a bookkeeping month.
| id | uuid | Unique, never reused. |
| client_id | uuid | The client it's for. |
| name | string | Job name. |
| period | string | null | Period label, e.g. “2025”. |
| status | string | Progress. |
| due_date | date | null | Due date. |
| assigned_to | uuid | null | Staff member doing it. |
| reviewer_id | uuid | null | Staff member reviewing it. |
| template_id | uuid | null | Template it came from. |
| completed_at | date-time | null | When it was finished. |
| created_at | date-time | When it was created. |
| updated_at | date-time | Last change. |
Lead
A prospect in the firm's sales pipeline.
| id | uuid | Unique, never reused. |
| name | string | Business or person. |
| contact_name | string | null | Who to talk to. |
| email | null | Email. | |
| phone | string | null | Phone. |
| entity_type | string | null | Kind of taxpayer, if known. |
| stage | string | Pipeline stage.new · contacted · meeting · proposal · won · lost |
| source | string | null | Where it came from (“API” when created through it). |
| estimated_value | number | null | Expected yearly fees, in dollars. |
| expected_close | date | null | When the firm expects to win it. |
| owner_id | uuid | null | Staff member who owns it. |
| website | string | null | Website. |
| converted_client_id | uuid | null | The client it became, once won. |
| created_at | date-time | When it was added. |
| updated_at | date-time | Last change. |
Event
Something that happened — the same shape in GET /events and in every webhook.
| id | uuid | Unique, never reused. |
| object | string | Always “event”. |
| api_version | string | The envelope version (a date). |
| type | string | What happened, e.g. filing.filed. |
| occurred_at | date-time | When. |
| firm_id | uuid | The firm. |
| client_id | uuid | null | The client it's about, if any. |
| subject_type | string | null | Kind of record: client, filing, invoice, job, step, lead. |
| subject_id | uuid | null | That record's id. |
| data | object | Ids, statuses, amounts and dates about the change — never names or messages. |
| record | object | null | The 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.
| id | uuid | Unique, never reused. |
| url | uri | Where Clerra POSTs. |
| description | string | null | Note. |
| events | string[] | Event types it receives. |
| enabled | boolean | False once switched off (by the firm, a 410 from the receiver, or 20 failures in a row). |
| disabled_reason | string | null | Why it was switched off. |
| created_at | date-time | When 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.created | A client is added | source, status, entity_type |
| client.status_changed | A client's status changes | from, to |
Filings
| filing.status_changed | A filing's status changes | from, to, rule_code, due_date |
| filing.filed | A filing is marked filed | rule_code, due_date, filed_source |
| filing.overdue | A filing becomes overdue | rule_code, due_date |
Invoices & payments
| invoice.created | An invoice is created | source, status, total_cents |
| invoice.sent | An invoice is sent | total_cents, due_date |
| invoice.paid | An invoice is paid in full | total_cents, source |
| payment.received | A payment is received | amount_cents, source, paid_in_full, paid_on |
| invoice.overdue | An invoice becomes overdue | due_date, balance_cents |
| invoice.payment_failed | A card payment fails |
Jobs
| job.created | A job is created | source, template_id, obligation_id |
| job.status_changed | A job's status changes | from, to |
| job.step_completed | A job step is completed | job_id, auto |
Sales pipeline
| lead.created | A lead is added | source, stage |
| lead.stage_changed | A lead moves stage | from, 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.
{
"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-Signature | string | t=<unix seconds>,v1=<hex HMAC-SHA256 of “t.body” with your signing secret>. |
| Clerra-Event-Id | string | The event id — the same on every retry. Use it to ignore duplicates. |
| Clerra-Event-Type | string | e.g. filing.filed. |
| Clerra-Delivery-Id | string | This delivery. |
Check the signature
Always verify before trusting a message: recompute the HMAC over the raw body and refuse anything older than five minutes.
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));
}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-Idto skip repeats andoccurred_atto order. recordis 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.
