Skoolsewa - School Management System Docs
Developer ResourcesAccounting integration

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

AreaFiles Inspected
Admin controllersapps/api/src/modules/integration/clients/*.controller.ts
External controllersapps/api/src/modules/integration/fees-api/*.controller.ts
DTOsapps/api/src/modules/integration/clients/dto/*.ts, apps/api/src/modules/integration/fees-api/dto/*.ts
Authapps/api/src/modules/integration/auth/integration-api-key.guard.ts, integration-endpoint.decorator.ts
Signingapps/api/src/utils/integration/webhook-signature.util.ts
Errorsapps/api/src/common/types/error-codes.ts (INTEGRATION_*)

2. Module Summary

FieldValue
Module slugintegration
Primary actorsOffice (admin), the accounting application (API key)
API surfacesAdmin (/api/integration-clients), external (/api/integration/fees)
Auth modelAdmin: JwtAuthGuard + RoleGuard + FeeIntegrations_*. External: Authorization: Bearer ssk_… + scope
Runtime source of truthintegration_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.

MethodPathPermissionSwitchReturns
GET/api/integration-clientsREAD—Paginated client list
POST/api/integration-clientsCREATEgated201, client detail
GET/api/integration-clients/:idREAD—Client detail (keys + webhooks + integrationConfigured)
PATCH/api/integration-clients/:idUPDATEgatedClient detail
POST/api/integration-clients/:id/suspendUPDATE—201, client detail
POST/api/integration-clients/:id/activateUPDATEgated201, client detail
POST/api/integration-clients/:id/revokeUPDATE—201, client detail (terminal)
GET/api/integration-clients/:id/feed-statusREAD—{epoch, head, lastPolledAt, lagEvents}
POST/api/integration-clients/:id/keysCREATEgated201, {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/revokeUPDATE—201, key
GET/api/integration-clients/:id/webhooksREAD—Webhook list
POST/api/integration-clients/:id/webhooksCREATEgated201, {webhook, webhookSecret} — shown once
PATCH/api/integration-clients/:id/webhooks/:webhookIdUPDATE—Webhook
POST/api/integration-clients/:id/webhooks/:webhookId/rotate-secretUPDATEgated201, {webhook, webhookSecret} — shown once
POST/api/integration-clients/:id/webhooks/:webhookId/testUPDATEgated201, delivery
GET/api/integration-clients/:id/webhooks/:webhookId/deliveriesREAD—Paginated deliveries, status filter
POST/api/integration-clients/:id/webhook-deliveries/:deliveryId/retryUPDATEgated201, 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

ScopeGrants
fees:readCatalogue, structures, scholarships/charges, obligations, ping/academic-context/changes
students:readThe student directory
payments:writePOST /payments, GET /payments/events/:eventId
mappings:writeStudent and invoice mapping reads/writes, and receives student_mapping.* webhook/feed events
payroll:readThe salary catalogue, structure, period, and payslip vocabulary and lifecycle — no personal identifiers
payroll:identity:readPAN, SSF, and CIT numbers (citizenship only when the school enables sharing it)
payroll:bank:readThe 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:writeReporting payment state back for a payslip
assets:readThe asset register's change feed — types, categories, register events
assets:writeReporting 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 page

limit 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

MethodPathScopePurpose
GET/api/integration/fees/pingany (*)Confirms the key, its scopes, server time, and the current feed epoch
GET/api/integration/fees/academic-contextfees:readSessions, periods (with BS/AD labels), terms, grades, sections — everything needed to interpret ids elsewhere
GET/api/integration/fees/schoolfees:read (add payroll:bank:read for bankName/bankAccountNumber)The school's identity, registration and contact facts — see GET /school below
GET/api/integration/schoolany (*, no scope)The same school read plus logoUrl and principal — see GET /school below
GET/api/integration/fees/changesfees:readThe change feed page — see B.7
GET/api/integration/fees/categoriesfees:readFee categories
GET/api/integration/fees/componentsfees:readFee components
GET/api/integration/fees/structuresfees:readFee structures
GET/api/integration/fees/structures/:idfees:readOne structure
GET/api/integration/fees/structures/:id/versions/:versionIdfees:readOne immutable version's lines
GET/api/integration/fees/scholarships, /scholarships/:id, /scholarships/:id/assignmentsfees:readScholarship definitions and their stints
GET/api/integration/fees/charges, /charges/:id, /charges/:id/assignmentsfees:readSame, for charges
GET/api/integration/fees/studentsstudents:readStudent directory, keyset paged, optional academicSessionId filter
GET/api/integration/fees/students/:idstudents:readOne student
GET/api/integration/fees/obligationsfees:readStudent fee obligations, keyset paged
GET/api/integration/fees/obligations/:idfees:readOne obligation with its lines
POST/api/integration/fees/paymentspayments:writeReport a payment event — see B.8
GET/api/integration/fees/payments/events/:eventIdpayments:writeThis client's own event, by ITS eventId
GET/api/integration/fees/student-mappingsmappings:writeThis client's student mappings
PUT/api/integration/fees/student-mappings/:studentIdmappings:writeUpsert (retire + create) a student mapping
PUT/api/integration/fees/invoice-mappings/:obligationIdmappings:writeUpsert an invoice mapping

B.6a Payroll routes

MethodPathScopePurpose
GET/api/integration/payroll/componentspayroll:readThe salary component vocabulary
GET/api/integration/payroll/structurespayroll:readSalary structures
GET/api/integration/payroll/structures/:idpayroll:readOne structure, with its current version's lines
GET/api/integration/payroll/employeespayroll:readEmployees on payroll, keyset paged
GET/api/integration/payroll/employees/:idpayroll:read (add payroll:identity:read/payroll:bank:read for identity/bank fields)One employee's payroll detail
GET/api/integration/payroll/periodspayroll:readSalary periods
GET/api/integration/payroll/periods/:idpayroll:readOne period
GET/api/integration/payroll/payslipspayroll:read (bank fields need payroll:bank:read)Payslips, keyset paged
GET/api/integration/payroll/payslips/:idpayroll:readOne payslip with its lines
POST/api/integration/payroll/paymentspayroll:writeReport a payment event for a payslip
GET/api/integration/payroll/payments/events/:eventIdpayroll:writeThis client's own reported payment event

B.6b Asset routes

MethodPathScopePurpose
GET/api/integration/assets/typesassets:readAsset type vocabulary
GET/api/integration/assets/categoriesassets:readAsset category vocabulary
GET/api/integration/assets/events/:eventIdassets:readThis client's own reported accounting event
GET/api/integration/assetsassets:readThe asset register, keyset paged
GET/api/integration/assets/:idassets:readOne asset
POST/api/integration/assets/:id/accountingassets:writeReport 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):

OutcomeMeaning
succeededApplied 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_conflictTwo events on one transactionRef disagree on identity — requires the office to resolve manually
rejectedThe obligation is cancelled, or the payload otherwise cannot be applied
failedThe 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:

  1. Verify the signature before doing anything else.
  2. Respond 2xx as soon as the signature and shape are accepted, ideally before any slow processing.
  3. 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 id field, not on the X-Skoolsewa-Event-Id header. Only "<timestamp>.<raw body>" is covered by the signature — the X-Skoolsewa-* id headers are not signed, so the body's own id is the value to trust for de-duplication.
  4. Fetch the full resource via the matching GET endpoint using the event's resource.id — the webhook body never carries the resource itself.

B.11 Every error code (INTEGRATION_*)

Error CodeHTTP StatusCondition
ACCOUNTING_INTEGRATION_DISABLED404ACCOUNTING_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_INVALID401Missing, 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_TAKEN409(admin) Another client already has this name
INTEGRATION_CLIENT_NOT_FOUND404(admin) Unknown client id
INTEGRATION_CLIENT_STATUS_TRANSITION_INVALID409(admin) A write attempted on a revoked client, or an invalid transition
INTEGRATION_CURSOR_EXPIRED410The feed cursor's epoch no longer matches — resync from scratch; details: { reason, resetAt } names why and when
INTEGRATION_CURSOR_INVALID400A malformed or tampered cursor (feed or keyset)
INTEGRATION_DELIVERY_ALREADY_SUCCEEDED409(admin) Retrying a delivery that already succeeded
INTEGRATION_DELIVERY_NOT_FOUND404Unknown delivery id, or one belonging to another client
INTEGRATION_EVENT_TYPE_NOT_ALLOWED400(admin) A webhook subscribed to an event type the client's scopes do not permit
INTEGRATION_KEY_LIMIT409(admin) A client already holds 2 active, unexpired keys
INTEGRATION_KEY_NOT_FOUND404(admin) Unknown key id
INTEGRATION_NOT_CONFIGURED503 (read) / 409 (a write that would mint a credential)INTEGRATION_SECRET_KEYS is not set on the server
INTEGRATION_PAYLOAD_TOO_LARGE413A declared body exceeds 32 KB, refused before it is read
INTEGRATION_SCOPE_MISSING403The key lacks a scope the route requires (or, internally, a route declares none)
INTEGRATION_VERSION_CONFLICT409(admin) A supplied version no longer matches the row
INTEGRATION_WEBHOOK_DISABLED409(admin) POST .../test on a disabled endpoint
INTEGRATION_WEBHOOK_LIMIT409(admin) A client already holds 5 non-disabled endpoints
INTEGRATION_WEBHOOK_NOT_FOUND404Unknown webhook id, or one belonging to another client
INTEGRATION_WEBHOOK_URL_INVALID400The 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