Accounting Integration API Reference
Complete API contracts for the accounting integration — client administration and the accounting application's own developer guide.
Accounting Integration - API Reference
Audience: The office (client administration), and the accounting application's own developers (the /api/integration/fees/** surface — the main content of this page).
Scope: /api/integration-clients/** (staff JWT + permission) and /api/integration/fees/** (API key).
1. Documentation Evidence
| Area | Files Inspected |
|---|---|
| Admin controllers | apps/api/src/modules/integration/clients/*.controller.ts |
| External controllers | apps/api/src/modules/integration/fees-api/*.controller.ts |
| DTOs | apps/api/src/modules/integration/clients/dto/*.ts, apps/api/src/modules/integration/fees-api/dto/*.ts |
| Auth | apps/api/src/modules/integration/auth/integration-api-key.guard.ts, integration-endpoint.decorator.ts |
| Signing | apps/api/src/utils/integration/webhook-signature.util.ts |
| Errors | apps/api/src/common/types/error-codes.ts (INTEGRATION_*) |
2. Module Summary
| Field | Value |
|---|---|
| Module slug | integration |
| Primary actors | Office (admin), the accounting application (API key) |
| API surfaces | Admin (/api/integration-clients), external (/api/integration/fees) |
| Auth model | Admin: JwtAuthGuard + RoleGuard + FeeIntegrations_*. External: Authorization: Bearer ssk_… + scope |
| Runtime source of truth | integration_clients/_client_keys/_webhook_endpoints (credentials); fee_* tables (data, owned by the Fees module) |
Part A — Client Administration (office, staff JWT)
A.1 Route Map
All under @Controller("integration-clients"), FeeIntegrations_{READ,CREATE,UPDATE}. The "Switch"
column marks routes carrying @RequiresAccountingIntegration(): while ACCOUNTING_INTEGRATION_ENABLED
is false, they answer 404 ACCOUNTING_INTEGRATION_DISABLED instead of running (checked after
JwtAuthGuard/RoleGuard, so an unauthenticated or unauthorised caller still gets 401/403). Every
other row stays reachable regardless of the switch — listing, suspending, and revoking a client,
revoking a key, listing webhooks and their deliveries, and reading feed status are never gated,
because revocation must stay possible even while the school is not using the integration.
| Method | Path | Permission | Switch | Returns |
|---|---|---|---|---|
GET | /api/integration-clients | READ | — | Paginated client list |
POST | /api/integration-clients | CREATE | gated | 201, client detail |
GET | /api/integration-clients/:id | READ | — | Client detail (keys + webhooks + integrationConfigured) |
PATCH | /api/integration-clients/:id | UPDATE | gated | Client detail |
POST | /api/integration-clients/:id/suspend | UPDATE | — | 201, client detail |
POST | /api/integration-clients/:id/activate | UPDATE | gated | 201, client detail |
POST | /api/integration-clients/:id/revoke | UPDATE | — | 201, client detail (terminal) |
GET | /api/integration-clients/:id/feed-status | READ | — | {epoch, head, lastPolledAt, lagEvents} |
POST | /api/integration-clients/:id/keys | CREATE | gated | 201, {key, apiKeySecret} — secret shown once, Cache-Control: no-store. A key carries every scope on the client, so a client holding payroll:* or assets:* scopes needs the same permissions as granting them (403 INTEGRATION_SCOPE_GRANT_FORBIDDEN); so does re-activating a suspended client |
POST | /api/integration-clients/:id/keys/:keyId/revoke | UPDATE | — | 201, key |
GET | /api/integration-clients/:id/webhooks | READ | — | Webhook list |
POST | /api/integration-clients/:id/webhooks | CREATE | gated | 201, {webhook, webhookSecret} — shown once |
PATCH | /api/integration-clients/:id/webhooks/:webhookId | UPDATE | — | Webhook |
POST | /api/integration-clients/:id/webhooks/:webhookId/rotate-secret | UPDATE | gated | 201, {webhook, webhookSecret} — shown once |
POST | /api/integration-clients/:id/webhooks/:webhookId/test | UPDATE | gated | 201, delivery |
GET | /api/integration-clients/:id/webhooks/:webhookId/deliveries | READ | — | Paginated deliveries, status filter |
POST | /api/integration-clients/:id/webhook-deliveries/:deliveryId/retry | UPDATE | gated | 201, delivery |
A.2 Key DTOs
CreateIntegrationClientDto: name (1–120), description? (≤500), scopes (1–10 of INTEGRATION_SCOPES, the size of that list), rateLimitPerMinute? (1–6000, default 120). UpdateIntegrationClientDto adds version and allows description: null to clear it. CreateIntegrationWebhookDto: url (≤2048), description?, eventTypes (≥1 of INTEGRATION_EVENT_TYPES).
A.3 Provisioning walkthrough
# 1. Generate a keyring master key (operator, once per deployment):
node -e "console.log('k1:'+require('crypto').randomBytes(32).toString('base64'))"
# 2. Put the output in apps/api/.env:
INTEGRATION_SECRET_KEYS=k1:base64-32-bytes-here
INTEGRATION_WEBHOOK_ALLOW_PRIVATE_TARGETS=false
# 3. Restart the API. Unset -> every integration route answers
# 503/409 INTEGRATION_NOT_CONFIGURED.Then, from the admin panel or curl: create a client, issue its first key, hand the token to the vendor out of band (it is never shown again), and register a webhook endpoint if the vendor wants push instead of polling the change feed.
Part B — The Accounting Application's API (/api/integration/fees/**)
B.1 Authentication
Authorization: Bearer ssk_a1b2c3d4e5f60718_qy8m3zK9...Token shape: ssk_<16 hex prefix>_<43-char base64url secret>. Every route requires this header; there is no session, no cookie, no CSRF concern. A missing, malformed, expired, or revoked key all answer identically:
{ "statusCode": 401, "message": "The API key is missing, invalid, expired or revoked.", "errorCode": "INTEGRATION_AUTH_INVALID" }Every response, success or failure, carries Cache-Control: no-store.
While ACCOUNTING_INTEGRATION_ENABLED is false on the server, every route under
/api/integration/** answers 404 ACCOUNTING_INTEGRATION_DISABLED before the key is even read — the
switch check is IntegrationApiKeyGuard's first statement, ahead of authentication.
B.2 Scopes
| Scope | Grants |
|---|---|
fees:read | Catalogue, structures, scholarships/charges, obligations, ping/academic-context/changes |
students:read | The student directory |
payments:write | POST /payments, GET /payments/events/:eventId |
mappings:write | Student and invoice mapping reads/writes, and receives student_mapping.* webhook/feed events |
payroll:read | The salary catalogue, structure, period, and payslip vocabulary and lifecycle — no personal identifiers |
payroll:identity:read | PAN, SSF, and CIT numbers (citizenship only when the school enables sharing it) |
payroll:bank:read | The bank details snapshotted onto an approved payslip, and the school's own bankName/bankAccountNumber on GET /fees/school — the same scope gates both, so a client granted it for one purpose can read the other. Granting it to unlock the school's own bank details on GET /fees/school also hands that client every employee's bank details through the payroll employee read, and vice versa. |
payroll:write | Reporting payment state back for a payslip |
assets:read | The asset register's change feed — types, categories, register events |
assets:write | Reporting accounting status, book value, and depreciation state back for an asset |
Granting or removing any payroll:* or assets:* scope on a client is a salary or asset permission
decision, not a FeeIntegrations one — the admin performing the grant needs SalarySync_UPDATE
(and StaffSalary_READ as well, for payroll:identity:read/payroll:bank:read) or
AssetSync_UPDATE on their own role, checked against every scope added or removed in the same
request (scopes are replaced as a whole set). Superadmin is exempt. A grant without the matching
admin permission fails:
{ "statusCode": 403, "message": "Granting payroll or asset access to an accounting client needs the salary or asset permission for it.", "errorCode": "INTEGRATION_SCOPE_GRANT_FORBIDDEN" }A route needing a scope the key lacks answers 403:
{ "statusCode": 403, "message": "This endpoint needs the scope fees:read.", "errorCode": "INTEGRATION_SCOPE_MISSING" }B.3 Rate limits
Per-client, per-minute, set at rateLimitPerMinute on the client (default 120). Exceeding it:
HTTP/1.1 429 Too Many Requests
Retry-After: 37{ "statusCode": 429, "message": "Too many requests.", "errorCode": "RATE_LIMIT_EXCEEDED" }An IP-level throttle (600 requests/minute) also applies, ahead of authentication — it exists to bound abuse before a key is even checked, not as a per-client budget.
B.4 Request size
Any request declaring a body over 32 KB is refused before it is read: 413, INTEGRATION_PAYLOAD_TOO_LARGE. A chunked request with no Content-Length is refused the same way, unread, because its size cannot be bounded up front. Both checks run ahead of the global body parser, so an oversized body is never buffered.
B.5 Keyset paging
Every list in this API (except changes, which has its own cursor — see B.7) is ordered by public_id ASC (uuidv7, so creation order) and paged by an OPAQUE cursor:
GET /api/integration/fees/students?limit=100
→ { "items": [...100 rows...], "nextCursor": "djE6MDE5OWEzYjIt...", "hasMore": true }
GET /api/integration/fees/students?limit=100&cursor=djE6MDE5OWEzYjIt...
→ the next pagelimit defaults to 100, max 500. nextCursor is null exactly when fewer than limit rows came back — that is the end-of-list signal; do not also check hasMore separately, though it is provided for convenience. A cursor is base64url of v1:<last row's public id> with a canonical round-trip check: any tampering, truncation, or hand-built cursor is refused outright rather than best-effort parsed:
{ "statusCode": 400, "message": "The cursor is not one this API issued.", "errorCode": "INTEGRATION_CURSOR_INVALID" }Never construct a cursor yourself — treat it as opaque and always pass back exactly what nextCursor gave you.
B.6 Endpoint reference
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/integration/fees/ping | any (*) | Confirms the key, its scopes, server time, and the current feed epoch |
GET | /api/integration/fees/academic-context | fees:read | Sessions, periods (with BS/AD labels), terms, grades, sections — everything needed to interpret ids elsewhere |
GET | /api/integration/fees/school | fees:read (add payroll:bank:read for bankName/bankAccountNumber) | The school's identity, registration and contact facts — see GET /school below |
GET | /api/integration/school | any (*, no scope) | The same school read plus logoUrl and principal — see GET /school below |
GET | /api/integration/fees/changes | fees:read | The change feed page — see B.7 |
GET | /api/integration/fees/categories | fees:read | Fee categories |
GET | /api/integration/fees/components | fees:read | Fee components |
GET | /api/integration/fees/structures | fees:read | Fee structures |
GET | /api/integration/fees/structures/:id | fees:read | One structure |
GET | /api/integration/fees/structures/:id/versions/:versionId | fees:read | One immutable version's lines |
GET | /api/integration/fees/scholarships, /scholarships/:id, /scholarships/:id/assignments | fees:read | Scholarship definitions and their stints |
GET | /api/integration/fees/charges, /charges/:id, /charges/:id/assignments | fees:read | Same, for charges |
GET | /api/integration/fees/students | students:read | Student directory, keyset paged, optional academicSessionId filter |
GET | /api/integration/fees/students/:id | students:read | One student |
GET | /api/integration/fees/obligations | fees:read | Student fee obligations, keyset paged |
GET | /api/integration/fees/obligations/:id | fees:read | One obligation with its lines |
POST | /api/integration/fees/payments | payments:write | Report a payment event — see B.8 |
GET | /api/integration/fees/payments/events/:eventId | payments:write | This client's own event, by ITS eventId |
GET | /api/integration/fees/student-mappings | mappings:write | This client's student mappings |
PUT | /api/integration/fees/student-mappings/:studentId | mappings:write | Upsert (retire + create) a student mapping |
PUT | /api/integration/fees/invoice-mappings/:obligationId | mappings:write | Upsert an invoice mapping |
B.6a Payroll routes
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/integration/payroll/components | payroll:read | The salary component vocabulary |
GET | /api/integration/payroll/structures | payroll:read | Salary structures |
GET | /api/integration/payroll/structures/:id | payroll:read | One structure, with its current version's lines |
GET | /api/integration/payroll/employees | payroll:read | Employees on payroll, keyset paged |
GET | /api/integration/payroll/employees/:id | payroll:read (add payroll:identity:read/payroll:bank:read for identity/bank fields) | One employee's payroll detail |
GET | /api/integration/payroll/periods | payroll:read | Salary periods |
GET | /api/integration/payroll/periods/:id | payroll:read | One period |
GET | /api/integration/payroll/payslips | payroll:read (bank fields need payroll:bank:read) | Payslips, keyset paged |
GET | /api/integration/payroll/payslips/:id | payroll:read | One payslip with its lines |
POST | /api/integration/payroll/payments | payroll:write | Report a payment event for a payslip |
GET | /api/integration/payroll/payments/events/:eventId | payroll:write | This client's own reported payment event |
B.6b Asset routes
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/integration/assets/types | assets:read | Asset type vocabulary |
GET | /api/integration/assets/categories | assets:read | Asset category vocabulary |
GET | /api/integration/assets/events/:eventId | assets:read | This client's own reported accounting event |
GET | /api/integration/assets | assets:read | The asset register, keyset paged |
GET | /api/integration/assets/:id | assets:read | One asset |
POST | /api/integration/assets/:id/accounting | assets:write | Report accounting status, book value, or depreciation state for an asset |
GET /ping
{ "message": "pong", "data": { "client": { "id": "0199a3b2-...", "name": "Books by Example" }, "scopes": ["fees:read", "payments:write"], "serverTime": "2026-09-22T12:00:00.000Z", "feedEpoch": "0199a3b2-7c1e-..." } }GET /academic-context?academicSessionId=
Returns every session (or one, filtered), each with its periods (BS year/month, a human label like "Baisakh 2087", AD start/end, dueDate), terms, and the grade/section lookup lists — the reference data every other endpoint's ids resolve against.
GET /students/:id
{ "message": "Student fetched.", "data": { "id": "0199a3b2-...", "studentCode": "SID-2083-000123", "name": "Aarav Sharma", "enrollment": { "academicSessionId": "...", "classId": "...", "gradeId": "...", "gradeName": "Grade 5", "sectionId": "...", "sectionName": "A", "rollNumber": 12, "status": "active", "enrolledOn": "2026-04-01", "endedOn": null } } }enrollment is null for a student with no CURRENT enrolment when no academicSessionId filter narrows which one is shown. enrollment.classId is the public_id (uuid) of the class the enrolment belongs to — the class is the grade and section pair within one academic session, so classId identifies it directly where gradeId and sectionId identify its two halves.
GET /payroll/payslips/:id
{ "message": "Payslip fetched.", "data": { "id": "0199a3b2-...", "periodId": "0199a3b2-...", "staffId": "0199a3b2-...", "employeeCode": "EMP-0007", "employeeName": "Sita Karki", "isTeaching": true, "revision": 1, "grossEarningsPaisa": 5000000, "leaveDeductionPaisa": 0, "totalDeductionsPaisa": 500000, "netPayPaisa": 4500000, "employerContributionsPaisa": 500000, "paymentStatus": "pending", "paidAmountPaisa": 0, "paidOn": null, "accountingReference": null, "lines": [{ "componentId": "0199a3b2-...", "code": "BASIC", "name": "Basic salary", "kind": "earning", "role": "basic", "amountPaisa": 5000000 }] } }Only APPROVED payslips of a sent or completed period are served. isTeaching is the flag stored on the payslip itself at the moment its current revision was computed (salary_payslips.is_teaching, from the staff member's designation then); it does not follow a later change to the staff member's designation. bank (bankName, accountNumber, branch, accountHolder) is present only when the key holds payroll:bank:read. The list route GET /payroll/payslips?periodId= serves the same shape per item.
GET /school
The school's identity, registration and contact facts, read from the same school_profile singleton row /api/school-profile serves the admin panel from — but as its own DTO (IntegrationSchoolDto), not a reuse of the admin response, so the two can evolve independently.
{ "message": "School fetched.", "data": { "name": "Green Valley School", "iemisCode": "12345678", "registrationNumber": "REG-2019-0042", "panNumber": "123456789", "address": "Ward 5, Suryabinayak, Bhaktapur, Bagmati", "phone": "+977-1-4123456", "email": "info@school.edu.np", "website": "https://school.edu.np", "currencyCode": "NPR", "timezone": "Asia/Kathmandu", "establishedYear": 1998, "updatedAt": "2026-01-10T04:00:00.000Z" } }GET /api/integration/school is readable by any active integration key (no scope): the school's public identity — name, PAN, address, contacts, logo, principal name/designation. Bank details require payroll:bank:read. It also returns logoUrl (an absolute public https: URL, or null — an http:, loopback, link-local or private-host logo is never returned) and principal (fullName nullable, designationName; null when no one holds the post).
bankName and bankAccountNumber are present only when the caller's key holds payroll:bank:read in addition to fees:read — both fields together, or neither; there is no way to read one without the other through this endpoint. address is a single formatted string — "Ward <n>, <tole>, <municipality>, <district>, <province>" — built by joining whichever of those five segments are non-blank with ", ", in that fixed order, and is null when none of them is set (rather than an empty string or a string of stray commas). establishedYear is Gregorian (AD), never Bikram Sambat. SCHOOL_PROFILE_NOT_INITIALIZED (404) is returned if row 1 of school_profile is somehow missing — documented elsewhere as unreachable outside a database not built by the migrations.
B.7 The change feed
GET /api/integration/fees/changes?cursor=<opaque>&limit=100{
"message": "Changes fetched.",
"data": {
"events": [
{ "id": "0199a3b2-...", "type": "student_fee.created", "occurredAt": "2026-09-22T09:00:01Z", "resource": { "type": "student_fee", "id": "0199a3b2-..." }, "academicSessionId": "0199a3b2-..." }
],
"nextCursor": "djE6MDE5OWE...",
"hasMore": false
}
}The cursor here is DIFFERENT from the keyset cursor in B.5 — it encodes (epoch, xactId, id), the feed's own position, not a bare row id. An event carries NO payload beyond its type and the resource's public id — always fetch the authoritative record through the corresponding read endpoint. Events are ordered so that a client that persists nextCursor after every successful page and resumes from it will never see an event twice out of order, and will never miss one that was still mid-transaction when an earlier page was read.
Epoch and 410. If the school's data is restored from a backup, or an operational anomaly moves the cluster's transaction ids backward relative to the feed, the epoch changes and EVERY outstanding cursor from before it becomes invalid:
{
"statusCode": 410,
"message": "The feed was reset (the school's data was restored). Resynchronise from scratch without a cursor, and resend any payment event whose GET now answers 404.",
"errorCode": "INTEGRATION_CURSOR_EXPIRED",
"details": { "reason": "restored_from_backup", "resetAt": "2026-09-22T04:00:00.000Z" }
}details.reason and details.resetAt come from the feed's own reset record and may both be null
if that record is unexpectedly missing. On a 410: drop the stored cursor, call changes with no
cursor at all, and treat everything from there as if it were new (re-fetch and reconcile rather
than assuming incremental deltas). A payment event your last cursor already read may be gone if it
was accepted after the point the data was restored to — check with GET payments/events/:eventId, and resend anything that now answers 404; payment intake is idempotent
per client and event id, so resending is always safe. A malformed cursor (never issued by this API)
is 400 INTEGRATION_CURSOR_INVALID instead — a different failure with a different fix (do not
silently start over; something is wrong with what you stored).
Recommended polling pattern: poll every few minutes, or register a webhook and use the feed only as a catch-up/reconciliation mechanism (GET .../feed-status on the admin side shows lagEvents if you want to monitor your own consumer's health from the school's side).
B.8 Payment intake
POST /api/integration/fees/payments{
"eventId": "acct-2026-09-22-000123",
"transactionRef": "TXN-88214",
"status": "completed",
"amountPaisa": 300000,
"currency": "NPR",
"paidOn": "2026-09-22",
"externalUpdatedAt": "2026-09-22T09:15:00+05:45",
"obligationId": "0199a3b2-...",
"reason": "Term 1 fee, cash"
}Either obligationId, studentId, or studentRef + period must be enough to resolve identity — obligationId is the strongest hint and is preferred when known. Response, 202:
{ "message": "Payment received; processing is asynchronous.", "data": { "syncEventId": "0199a3b2-...", "eventId": "acct-2026-09-22-000123", "status": "pending", "receivedAt": "2026-09-22T09:15:02Z", "replayed": false } }Idempotency. eventId is YOUR OWN key, 1–128 characters of [A-Za-z0-9._:-]. Posting the identical event again returns the SAME syncEventId with replayed: true and does not process twice — safe to retry on a timeout or an ambiguous response. Posting the SAME eventId with a DIFFERENT body is refused (FEE_SYNC_EVENT_ID_REUSED) rather than silently accepted as a correction — send a NEW event with a new eventId for a correction, or a status: "reversed" event referencing the same transactionRef.
Ordering. externalUpdatedAt is the authority for "what does the accounting system currently believe", NOT the order events arrive at this API. If two events for the same transactionRef arrive out of network order, the one with the LATER externalUpdatedAt wins regardless of receipt order.
Outcomes (poll GET /payments/events/:eventId to see which):
| Outcome | Meaning |
|---|---|
succeeded | Applied to an obligation; fee_payment_entries written |
held_unmatched (transaction state) | No identity hint resolved to a student/obligation — held until a mapping exists or a later event supplies one; the daily re-match sweep retries automatically |
held_conflict | Two events on one transactionRef disagree on identity — requires the office to resolve manually |
rejected | The obligation is cancelled, or the payload otherwise cannot be applied |
failed | The final processing attempt threw; the office was notified — will not silently retry forever |
B.9 Mappings
PUT /api/integration/fees/student-mappings/:studentId
Body: { "externalRef": "CUST-4471" }An upsert: any existing active mapping for this (client, student) is RETIRED and a new one created — never overwritten in place, so the history of "who this pupil was known as" is preserved. The same shape applies to PUT /invoice-mappings/:obligationId with invoiceRef.
B.10 Webhooks
Headers on every delivery:
X-Skoolsewa-Event-Id: 0199a3b2-...
X-Skoolsewa-Event-Type: student_fee.created
X-Skoolsewa-Delivery-Id: 0199a3b2-...
X-Skoolsewa-Timestamp: 1893456000
X-Skoolsewa-Signature: v1=<hex hmac>Signature scheme: v1=HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex-encoded. During a secret rotation's 24-hour grace window, the header carries TWO comma-separated v1= values (the new secret first) — verify against either. Tolerance: reject anything more than 300 seconds from your own clock as stale (guards against replay).
Node.js verification snippet (mirrors apps/api/src/utils/integration/webhook-signature.util.ts exactly):
const { createHmac, timingSafeEqual } = require("node:crypto");
function verifyWebhook({ secret, header, timestamp, rawBody, nowSeconds, toleranceSeconds = 300 }) {
if (!/^\d{1,12}$/.test(timestamp)) return { valid: false, reason: "malformed" };
const ts = Number(timestamp);
if (Math.abs(nowSeconds - ts) > toleranceSeconds) return { valid: false, reason: "stale" };
const candidates = header
.split(",")
.map((p) => p.trim())
.filter((p) => p.startsWith("v1="))
.map((p) => p.slice(3))
.filter((hex) => /^[0-9a-f]{64}$/.test(hex));
if (candidates.length === 0) return { valid: false, reason: "malformed" };
const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`, "utf8").digest();
const matched = candidates.some((hex) => {
const given = Buffer.from(hex, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
return matched ? { valid: true } : { valid: false, reason: "mismatch" };
}
// Usage in an Express-style handler, given the RAW body (not re-serialized JSON):
const result = verifyWebhook({
secret: Buffer.from(YOUR_WEBHOOK_SECRET, "utf8"),
header: req.headers["x-skoolsewa-signature"],
timestamp: req.headers["x-skoolsewa-timestamp"],
rawBody: req.rawBody, // the exact bytes received, before any JSON.parse
nowSeconds: Math.floor(Date.now() / 1000),
});
if (!result.valid) return res.status(401).end();Body:
{ "id": "0199a3b2-...", "type": "student_fee.created", "occurredAt": "2026-09-22T09:00:01Z", "resource": { "type": "student_fee", "id": "0199a3b2-..." }, "academicSessionId": "0199a3b2-..." }A kind: "test" delivery (from the office's "Send test" button) carries "type": "test" and "resource": null — handle it as a connectivity check, not a real event.
Retry schedule. On a non-2xx or a network error, retried at: 1m, 5m, 30m, 2h, 6h, 12h, 24h, 24h — 8 retries, 9 attempts total. After the 9th failure the delivery is dead permanently (poll or ask the office to retry it from the admin panel; there is no automatic 10th attempt). Respond 2xx quickly and process asynchronously on your side if handling takes any real time — the sender does not wait long per attempt.
Your receiver should:
- Verify the signature before doing anything else.
- Respond
2xxas soon as the signature and shape are accepted, ideally before any slow processing. - Treat delivery as AT-LEAST-ONCE — the same event may arrive more than once (a retry after a slow-but-successful response, for instance); de-duplicate on the body's
idfield, not on theX-Skoolsewa-Event-Idheader. Only"<timestamp>.<raw body>"is covered by the signature — theX-Skoolsewa-*id headers are not signed, so the body's ownidis the value to trust for de-duplication. - Fetch the full resource via the matching
GETendpoint using the event'sresource.id— the webhook body never carries the resource itself.
B.11 Every error code (INTEGRATION_*)
| Error Code | HTTP Status | Condition |
|---|---|---|
ACCOUNTING_INTEGRATION_DISABLED | 404 | ACCOUNTING_INTEGRATION_ENABLED is false on the server — every /api/integration/** route, and the fees/salary/asset admin routes and admin actions marked "gated" in A.1, refuse this way instead of running |
INTEGRATION_AUTH_INVALID | 401 | Missing, malformed, unknown, expired, or revoked key — one answer for every reason; a key or client recorded as revoked outside the database (in Redis) is refused even if its row still reads active |
INTEGRATION_CLIENT_NAME_TAKEN | 409 | (admin) Another client already has this name |
INTEGRATION_CLIENT_NOT_FOUND | 404 | (admin) Unknown client id |
INTEGRATION_CLIENT_STATUS_TRANSITION_INVALID | 409 | (admin) A write attempted on a revoked client, or an invalid transition |
INTEGRATION_CURSOR_EXPIRED | 410 | The feed cursor's epoch no longer matches — resync from scratch; details: { reason, resetAt } names why and when |
INTEGRATION_CURSOR_INVALID | 400 | A malformed or tampered cursor (feed or keyset) |
INTEGRATION_DELIVERY_ALREADY_SUCCEEDED | 409 | (admin) Retrying a delivery that already succeeded |
INTEGRATION_DELIVERY_NOT_FOUND | 404 | Unknown delivery id, or one belonging to another client |
INTEGRATION_EVENT_TYPE_NOT_ALLOWED | 400 | (admin) A webhook subscribed to an event type the client's scopes do not permit |
INTEGRATION_KEY_LIMIT | 409 | (admin) A client already holds 2 active, unexpired keys |
INTEGRATION_KEY_NOT_FOUND | 404 | (admin) Unknown key id |
INTEGRATION_NOT_CONFIGURED | 503 (read) / 409 (a write that would mint a credential) | INTEGRATION_SECRET_KEYS is not set on the server |
INTEGRATION_PAYLOAD_TOO_LARGE | 413 | A declared body exceeds 32 KB, refused before it is read |
INTEGRATION_SCOPE_MISSING | 403 | The key lacks a scope the route requires (or, internally, a route declares none) |
INTEGRATION_VERSION_CONFLICT | 409 | (admin) A supplied version no longer matches the row |
INTEGRATION_WEBHOOK_DISABLED | 409 | (admin) POST .../test on a disabled endpoint |
INTEGRATION_WEBHOOK_LIMIT | 409 | (admin) A client already holds 5 non-disabled endpoints |
INTEGRATION_WEBHOOK_NOT_FOUND | 404 | Unknown webhook id, or one belonging to another client |
INTEGRATION_WEBHOOK_URL_INVALID | 400 | The URL fails scheme/host/port/credential/fragment validation |
Fee-domain outcomes reached THROUGH this API (payment identity conflicts, mapping conflicts) use the FEE_* codes documented in the Fees module's API doc, since the underlying record is owned there.
12. Diagrams
13. Zero-Omission API Checklist
- Every admin and external route is documented (Part A.1, B.6).
- Auth, scopes, rate limits, and request-size limits are documented (B.1–B.4).
- Keyset paging and the change feed's distinct cursor are both documented (B.5, B.7).
- Payment intake — body, idempotency, ordering, and every outcome — is documented (B.8).
- Mapping upsert semantics are documented (B.9).
- Webhook headers, signature scheme, rotation, retry schedule, and a working verification snippet are documented (B.10).
- Every
INTEGRATION_*error code is documented (B.11). - The doc links to backend and features/flows.
See Also
- Backend doc:
/docs/developer/integration/backend - Features and flows doc:
/docs/developer/integration/feature - Fee data itself:
/docs/developer/fees/api
Accounting Integration Backend Documentation
Backend architecture, data model, credentials, webhooks, the change feed, and operational behavior for the accounting integration module.
Assignments Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Assignments.