Projetex Automation Hub (API Documentation)
Offline-first REST API for Projetex SQL Server. Designed for
local use and optional LAN/Internet exposure later via config.
Features (v0.2)
- FastAPI + OpenAPI docs at
/docs - API key auth with role-based scopes
- Full CRUD for all major Projetex entities (330+ routes)
- Read-only reporting views (
/v1/views/*) - Automation views for recipient policy/resolution/snapshot
hydration - Admin-only SQL Server backup endpoint
- Webhooks via SQL Server Change Tracking
- WooCommerce Subscriptions license enforcement
Endpoint Summary
| Group | Scope | Entities | Â |
|---|---|---|---|
| Clients | clients:* | CLIENTS | Â |
| Client Accounts | clients:* | CCONTACTS, CACCOUNTS, CACCMANAGERS, CINFO | Â |
| Client Jobs | cjobs:* | CJOBS | Â |
| Client Quotes | billing:* | CMULTIQUOTES, CMULTIQUOTEITEMS, CQUOTESLOG | Â |
| Client Pricing | billing:* | CPRICES, CDISCOUNTDEFS, CTAXDEFS, CSERVICEDEFS, CUNITDEFS | Â |
| Client Billing | billing:* | CINVOICES, CPAYMENTS, CFINLINKS, CREFUNDS, CN_INVOICES, CN_FINLINKS | Â |
| Projects | projects:* | PROJECTS | Â |
| Project Extensions | projects:* | PINFO, PTEAM, TASKS, TASK_CODES, TASK_ROLES, JA | Â |
| Corporate Jobs | ejobs:* | EJOBS | Â |
| Freelance Jobs | rjobs:* | RJOBS | Â |
| Resources | resources:* | RESOURCES, RINFO, RLANG_PAIRS, RSOFTWARE | Â |
| Resource Financials | billing:* / resources:* | RINVOICES, RPAYMENTS, RFINLINKS, RQUOTES, RPRICES, RDISCOUNTDEFS, RTAXDEFS | Â |
| Employees | employees:* | EMPLOYEES, EINFO, ELANG_PAIRS, ESOFTWARE, ERANKS, ELEVELS | Â |
| Employee Financials | billing:* / employees:* | EPAYMENTS, EFINLINKS, EPRICES | Â |
| Global Roster | projects:read (read), admin:write(write) | LANGS, LANGS_ALL, COUNTRIES, EXCHRATES, SOFTWARE, SOFTWARE_TOOLS, TAXES, SALUTATIONS, PMETHODS, DISCOUNTS, UNITRATIOS | Â |
| Company Settings | admin:write | AITCOMPANY, ASETTINGS, ATYPES, ASUBTYPES, AITUSERS, EMAILTEMPLATES, ALERTS, INFSTAT |  |
| Lookups | projects:read | SERVICES, SERVICEGROUPS, UNITS, CURRENCIES, BILLING_STATES, custom fields | Â |
| Views | varies | Reporting views, billing, outstanding | Â |
| Automation | any | Recipient policy, resolution, snapshot hydration | Â |
| Admin | * | API keys, webhooks, backup | Â |
| Procedures | varies | Whitelisted stored procedures | Â |
Requirements
- Projetex 5D Server
Create API Key for a
Projetex User
Non-admin keys must be bound to an existingAIT$USERS.AIT$USER_ID.
Admin keys may omit --ait-user-id (they use-1 for DB context), but non-admin keys withoutait_user_id are rejected.
You can create keys via API (Admin only):
curl -X POST http://127.0.0.1:8844/v1/admin/api-keys \
-H "X-API-Key: <admin_key>" \
-H "Content-Type: application/json" \
-d '{"name":"PM John","role":"PM","ait_user_id":123}'WooCommerce Subscription
Licensing
The API enforces an active subscription license from translation3000.com. Licensing is
installation-wide: a single license key is set in.env, and all non-Admin requests are checked against it.
Admin keys are always exempt.
Enable in .env:
WC_SUBSCRIPTION_ENABLED=1
LICENSE_KEY=XXXX-XXXX-XXXX-XXXX
# How long to cache a successful check (default: 3600 = 1 hour)
LICENSE_CACHE_TTL_SECONDS=3600The installer writes these values automatically during setup. You
should not need to edit them manually.
Behavior summary
| Key type | WC_SUBSCRIPTION_ENABLED | Result |
|---|---|---|
| Admin | any | Always allowed |
| Non-admin | 1 | License key checked |
License management
endpoints (Admin only)
Check current installation license status:
curl http://127.0.0.1:8844/v1/admin/license -H "X-API-Key: <admin_key>"Force-refresh license cache (re-queries the license server
immediately):
curl -X POST http://127.0.0.1:8844/v1/admin/license/refresh -H "X-API-Key: <admin_key>"Deactivate a key immediately (e.g. to revoke access without waiting
for cache TTL):
curl -X PATCH http://127.0.0.1:8844/v1/admin/api-keys/5 \
-H "X-API-Key: <admin_key>" \
-H "Content-Type: application/json" \
-d '{"is_active":false}'Installer
- Projetex 5D detection — checks for the server
installation and verifies the SQL Server instance is reachable; exits
with a clear message if not found. - License activation page — prompts for the license
key from the customer’s purchase confirmation email; activates the seat
via the license server. - Windows service — installs and configures the API
as a Windows service (via NSSM, credits to https://nssm.cc/,
auto-start). - Admin key generation — generates admin key after
install and displays the key in a dialog. The key is copied to the
clipboard automatically – make sure to paste it into a file and store
securely. - Uninstaller — stops and removes the service, cleans
up files (some data is preserved, you may need to clean up
manually).
Admin keys across
reinstalls / upgrades
Each install run generates a new admin key and adds
it to the keys database. It does not delete or rotate
existing keys — the database is preserved across reinstalls. This
means:
- Any admin key from a previous installation continues to
work after an upgrade. - The new key shown at the end of setup is an additional key, not a
replacement. - If you want to revoke the old key after upgrading, do so via the
API:
# List keys to find the old key_id
curl http://127.0.0.1:8844/v1/admin/api-keys -H "X-API-Key: <new_admin_key>"
# Revoke it
curl -X DELETE http://127.0.0.1:8844/v1/admin/api-keys/<key_id> -H "X-API-Key: <new_admin_key>"Windows Service
Production installs: the installer handles service
registration automatically via NSSM. No manual steps needed.
API Key Roles & Scopes
Admin:*PM:clients:read,projects:read/write,cjobs:read/write,ejobs:read/write,rjobs:read/writeAccountant:clients:read,projects:read,jobs:read,billing:*CorporateExpert:ejobs:read/write_status_onlyFreelanceExpert:rjobs:read/write_status_only
Non-admin keys must be bound to a Projetex user
(AIT$USER_ID). Admin keys may omit user binding.
Row-Level Security
(Important)
CorporateExpert and FreelanceExpert access is
deny-by-default until API keys can be mapped toEMP_ID / RES_ID.
Webhooks (SQL Change
Tracking)
Webhooks are driven by SQL Server Change Tracking
(CHANGETABLE) and dispatched via an outbox.
Enable in .env (if not enabled):
WEBHOOKS_ENABLED=1
CHANGE_TRACKING_POLL_INTERVAL_SECONDS=5
WEBHOOKS_INCLUDE_DIFFS=1Create webhook (Admin only):
curl -X POST http://127.0.0.1:8844/v1/admin/webhooks \
-H "X-API-Key: <admin_key>" \
-H "Content-Type: application/json" \
-d '{"name":"Zapier","url":"https://example.com/webhook","events":["ejob.created","rjob.created"],"secret":"optional"}'Validation rules for events: – exact event names fromGET /v1/admin/webhooks/events – family wildcard:<family>.* where family exists – global wildcard:* – unknown events are rejected with 400 anderror.details.invalid_events
List webhooks:
curl http://127.0.0.1:8844/v1/admin/webhooks -H "X-API-Key: <admin_key>"List supported webhook events (authoritative):
curl http://127.0.0.1:8844/v1/admin/webhooks/events -H "X-API-Key: <admin_key>"Send synthetic webhook test event (no DB mutation):
curl -X POST http://127.0.0.1:8844/v1/admin/webhooks/test \
-H "X-API-Key: <admin_key>" \
-H "Content-Type: application/json" \
-d '{"event":"system.test","entity_id":{"PROJ_ID":15,"RES_ID":2},"payload":{"data":{"hello":"world"}}}'Minimal valid request (works):
curl -X POST http://127.0.0.1:8844/v1/admin/webhooks/test \
-H "X-API-Key: <admin_key>" \
-H "Content-Type: application/json" \
-d '{"event":"system.test"}'Webhook deliveries and operations:
curl http://127.0.0.1:8844/v1/admin/webhooks/deliveries?limit=100 -H "X-API-Key: <admin_key>"
curl http://127.0.0.1:8844/v1/admin/webhooks/1/deliveries?limit=100 -H "X-API-Key: <admin_key>"
curl -X POST http://127.0.0.1:8844/v1/admin/webhooks/1/deliveries/<delivery_id>/retry -H "X-API-Key: <admin_key>"
curl -X POST http://127.0.0.1:8844/v1/admin/webhooks/replay \
-H "X-API-Key: <admin_key>" \
-H "Content-Type: application/json" \
-d '{"from_time":"2026-02-16T00:00:00+00:00","to_time":"2026-02-16T23:59:59+00:00","event":"cquote.updated","entity_id":51,"limit":500}'
curl http://127.0.0.1:8844/v1/admin/webhooks/health -H "X-API-Key: <admin_key>"Events (examples): – client.created,client.updated, client.deleted –project.created, project.updated,project.completed, project.deleted –cjob.created, cjob.updated,cjob.completed, cjob.deleted –ejob.created, ejob.updated,ejob.assigned, ejob.completed,ejob.deleted – rjob.created,rjob.updated, rjob.assigned,rjob.completed, rjob.deleted –invoice.created, invoice.updated,invoice.fully_paid, invoice.deleted –payment.created, payment.updated,payment.deleted – po.created,po.updated, po.deleted –ja.created, ja.updated,ja.deleted – ccontact.created|updated|deleted
– caccount.created|updated|deleted –cquote.created|updated|deleted –cquote_item.created|updated|deleted –cprice.created|updated|deleted –eprice.created|updated|deleted –rprice.created|updated|deleted –cnote.created|updated|deleted –crefund.created|updated|deleted –pteam.created|updated|deleted –eexpense.created|updated|deleted –rquote.created|updated|deleted –cfinlink.created|updated|deleted –rfinlink.created|updated|deleted –efinlink.created|updated|deleted –cn_finlink.created|updated|deleted
Payload includes change_tracking metadata and a current
snapshot (data). If WEBHOOKS_INCLUDE_DIFFS=1,
payload includes changes computed from a locally stored
snapshot cache. Deletes include deleted.primary_key.
Webhook payload contract: – event_id: globally unique
per webhook outbox delivery (stable across retries) –occurred_at: UTC timestamp – event:
e.g. client.updated – entity_id: entity id (or
composite key object) – id: alias of entity_id
(kept for backward compatibility) – source: event producer,
currently sql_change_tracking –change_tracking.version: CT version that produced the event
(when available) – delivery_id: unique per retry attempt –idempotency_key: stable for retries of the same logical
outbox event – hydrate_url: API URL for canonical snapshot
hydration (scalar entity ids) – recipient_url: API URL for
recipient resolution (scalar entity ids)
Automation Views
These endpoints support API-first automation for recipient policy,
recipient resolution, and template hydration.
Get recipient policy:
curl http://127.0.0.1:8844/v1/automation/recipient-policy -H "X-API-Key: <key>"Response contains: – contract_version,generated_at –entities.<entity>.allowed_groups (ordered) –entities.<entity>.forbidden_groups –entities.<entity>.default_groups
Resolve recipients for an entity:
curl "http://127.0.0.1:8844/v1/views/automation/recipients/project/101?groups=project_freelancers_active,pm" \
-H "X-API-Key: <key>"Response contains: – contract_version,generated_at, entity, entity_id –groups[] with key, label,count, recipients[] – dedupe withstrategy, before, after
Get canonical snapshot for rules/templates:
curl "http://127.0.0.1:8844/v1/views/automation/snapshot/cinvoice/119?event_type=cinvoice.created&source=api_webhook&change_tracking_version=46" \
-H "X-API-Key: <key>"Response contains: – contract_version,generated_at, entity, entity_id,event_type – data (canonical entity payload) –changes (empty object when unavailable) –meta.source, meta.occurred_at, optionalmeta.change_tracking_version,meta.idempotency_key
Recipient policy enforcement: – Unknown/forbidden groups return400 with actionable error.details. –ejob families reject client andclient_contact. – rjob families rejectemployee.
Examples
Create client:
curl -X POST http://127.0.0.1:8844/v1/clients \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"name":"Acme","currency_code":"USD","country_name":"United States","type":"client"}'Notes: – If client_code/code is not
provided, the API builds a unique code from the name. – You can passcurrency_code (ISO 4217) or currency_id, andcountry_name or country_id. – Custom fields
like AIT$CUSTOMF000034 are validated againstAIT$FIELDS before insert. – ait_user_id is
optional; if omitted, the API uses -1 (default admin).
List projects for a client:
curl http://127.0.0.1:8844/v1/clients/10/projects -H "X-API-Key: <key>"Delete project (two-step confirmation):
curl -X DELETE "http://127.0.0.1:8844/v1/projects/18" -H "X-API-Key: <key>"This returns 409 with counts and a randomconfirm_code. Then confirm:
curl -X DELETE "http://127.0.0.1:8844/v1/projects/18?confirm_code=123456" -H "X-API-Key: <key>"The 409 payload also includes project_code
so you can verify you are deleting the intended project. NOTE: Project
ID and Project Code are not the same, so use exterme caution deleting
projects by their ID. Project IDs are not seen anywhere in the
applicaiton, you only see them wih API or SQL queries. Deleting a
project requires some time (up to a minute), so be patient. All project
files, if any, remain intact on the hard drive, changes are made to the
database only. Projects can not be restored after deletion.
Update client:
curl -X PUT http://127.0.0.1:8844/v1/clients/10 \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"name":"Acme Updated","client_code":"ACM100","client_email1":"billing@acme.example"}'Create corporate expert job (EJOB) minimal:
curl -X POST http://127.0.0.1:8844/v1/ejobs \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"cjob_id": 100, "project_id": 10, "service_id": 2, "unit_id": 3, "currency_id": 1}'Create corporate expert job (EJOB) full:
curl -X POST http://127.0.0.1:8844/v1/ejobs \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{
"cjob_id": 100,
"project_id": 10,
"name": "Editing",
"service_id": 2,
"unit_id": 3,
"currency_id": 1,
"employee_id": 5,
"volume": 1000,
"hourly_cost": 25,
"hours_spent": 4,
"price": 0,
"rate": 1,
"total": 100,
"fee_kind": "flat fee",
"deadline": "2026-02-28T12:00:00",
"instruction": "Follow the style guide",
"worknotes": "Initial pass only"
}'Update corporate expert job (EJOB) minimal:
curl -X PUT http://127.0.0.1:8844/v1/ejobs/200 \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"name":"Editing - updated"}'Update corporate expert job (EJOB) full:
curl -X PUT http://127.0.0.1:8844/v1/ejobs/200 \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{
"name": "Editing - updated",
"employee_id": 5,
"volume": 1200,
"hourly_cost": 30,
"hours_spent": 5,
"price": 0,
"rate": 1,
"total": 150,
"fee_kind": "flat fee",
"deadline": "2026-03-05T12:00:00",
"is_completed": false,
"instruction": "Follow the style guide",
"worknotes": "Update after review"
}'Create freelance job (RJOB) minimal:
curl -X POST http://127.0.0.1:8844/v1/rjobs \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"cjob_id": 100, "project_id": 10, "service_id": 2, "unit_id": 3, "currency_id": 1}'Create freelance job (RJOB) full:
curl -X POST http://127.0.0.1:8844/v1/rjobs \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{
"cjob_id": 100,
"project_id": 10,
"name": "Translation",
"service_id": 2,
"unit_id": 3,
"currency_id": 1,
"resource_id": 7,
"volume": 2000,
"price": 0,
"rate": 1,
"total": 200,
"fee_kind": "flat fee",
"deadline": "2026-02-28T12:00:00",
"instruction": "Use the glossary",
"worknotes": "Prefer literal translation"
}'Update freelance job (RJOB) minimal:
curl -X PUT http://127.0.0.1:8844/v1/rjobs/300 \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"name":"Translation - updated"}'Update freelance job (RJOB) full:
curl -X PUT http://127.0.0.1:8844/v1/rjobs/300 \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{
"name": "Translation - updated",
"resource_id": 7,
"volume": 2400,
"price": 0,
"rate": 1,
"total": 240,
"fee_kind": "flat fee",
"deadline": "2026-03-05T12:00:00",
"is_completed": false,
"instruction": "Use the glossary",
"worknotes": "Finalize after QA"
}'Delete client job (CJOB):
curl -X DELETE http://127.0.0.1:8844/v1/cjobs/100 \
-H "X-API-Key: <key>"Delete corporate job (EJOB):
curl -X DELETE http://127.0.0.1:8844/v1/ejobs/200 \
-H "X-API-Key: <key>"Delete freelance job (RJOB):
curl -X DELETE http://127.0.0.1:8844/v1/rjobs/300 \
-H "X-API-Key: <key>"PM deletion note: – Admins can delete any jobs. – PMs can delete jobs
only for projects where they are the PM. – Ownership is validated using
the API key’s bound AIT_USER_ID.
Assign a freelance job to a freelancer: – Preferred:POST /v1/rjobs/{rjob_id}/assign withresource_id. – You can still usePUT /v1/rjobs/{rjob_id} with resource_id for
assignment. – Unassign viaPOST /v1/rjobs/{rjob_id}/unassign. – Unassign is blocked if
the job is linked to a PO (RINV_ID).
Assign a corporate job to a corporate expert: – Preferred:POST /v1/ejobs/{ejob_id}/assign withemployee_id. – You can still usePUT /v1/ejobs/{ejob_id} with employee_id for
assignment. – Unassign viaPOST /v1/ejobs/{ejob_id}/unassign. – Unassign is blocked if
the job is linked to a JA (JA_ID).
Vacant expert jobs: – If employee_id /resource_id is omitted on creation, the job is created as
vacant. – Vacant jobs have EJOB_FNUMB /RJOB_FNUMB set to J-VACANT. – Unassigning a
completed expert job resets it to not completed.
Assign / unassign examples:
curl -X POST http://127.0.0.1:8844/v1/ejobs/200/assign \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"employee_id": 5}'
curl -X POST http://127.0.0.1:8844/v1/ejobs/200/unassign \
-H "X-API-Key: <key>"
curl -X POST http://127.0.0.1:8844/v1/rjobs/300/assign \
-H "X-API-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"resource_id": 7}'
curl -X POST http://127.0.0.1:8844/v1/rjobs/300/unassign \
-H "X-API-Key: <key>"PM assignment note: – Admin can assign/unassign any jobs. – PM can
assign/unassign only within their projects.
Get assignment info:
curl http://127.0.0.1:8844/v1/ejobs/200/assignment -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/rjobs/300/assignment -H "X-API-Key: <key>"Job code formatting: – Codes are stored in EJOB_FNUMB /RJOB_FNUMB (max 10 chars). – If EMP_CODE /RES_CODE is long, it will be truncated to fitJ-<CODE><NNNNN>.
Backup database (Admin only):
curl -X POST http://127.0.0.1:8844/v1/admin/backup -H "X-API-Key: <key>"Resources and employees:
curl http://127.0.0.1:8844/v1/resources -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7/info -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7/lang-pairs -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7/software -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5/info -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5/lang-pairs -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5/ranks -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5/levels -H "X-API-Key: <key>"Freelancer financials:
curl http://127.0.0.1:8844/v1/resource-invoices -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resource-invoices/10 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7/invoices -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resource-payments -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resource-payments/10/finlinks -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resource-quotes -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7/prices -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7/discount-defs -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/resources/7/tax-defs -H "X-API-Key: <key>"Employee financials:
curl http://127.0.0.1:8844/v1/employee-payments -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employee-payments/10 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5/payments -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employee-payments/10/finlinks -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5/prices -H "X-API-Key: <key>"Project extensions:
curl http://127.0.0.1:8844/v1/projects/15/info -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/projects/15/team -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/projects/15/tasks -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/projects/15/job-assignments -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/task-codes -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/task-roles -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/job-assignments/100 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/employees/5/job-assignments -H "X-API-Key: <key>"Client accounts and contacts:
curl http://127.0.0.1:8844/v1/clients/10/contacts -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/accounts -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/account-managers -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/info -H "X-API-Key: <key>"Client quotes and pricing:
curl http://127.0.0.1:8844/v1/client-quotes -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/client-quotes/50/items -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/client-quotes/50/log -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/quotes -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/prices -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/discount-defs -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/tax-defs -H "X-API-Key: <key>"Global roster (Admin write, any authenticated read):
curl http://127.0.0.1:8844/v1/roster/langs -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/langs-all -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/countries -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/exchrates -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/software -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/software-tools -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/taxes -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/salutations -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/payment-methods -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/discounts -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/roster/unit-ratios -H "X-API-Key: <key>"Company settings (Admin only):
curl http://127.0.0.1:8844/v1/company -H "X-API-Key: <admin_key>"
curl http://127.0.0.1:8844/v1/company/settings -H "X-API-Key: <admin_key>"
curl http://127.0.0.1:8844/v1/company/types -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/company/subtypes -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/company/users -H "X-API-Key: <admin_key>"
curl http://127.0.0.1:8844/v1/company/email-templates -H "X-API-Key: <admin_key>"
curl http://127.0.0.1:8844/v1/company/alerts -H "X-API-Key: <admin_key>"
curl http://127.0.0.1:8844/v1/company/info-statuses -H "X-API-Key: <key>"Lookups (services, units, currencies):
curl http://127.0.0.1:8844/v1/lookups/services -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/lookups/servicegroups -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/lookups/units -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/lookups/currencies -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/lookups/billing-states -H "X-API-Key: <key>"Custom fields (metadata for a table):
curl http://127.0.0.1:8844/v1/custom-fields/clients -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/custom-fields/projects -H "X-API-Key: <key>"Aliases:
curl http://127.0.0.1:8844/v1/clients/customf -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/projects/customf -H "X-API-Key: <key>"Invoices and payments (read-only):
curl http://127.0.0.1:8844/v1/invoices?page=1&page_size=50 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/payments?page=1&page_size=50 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/invoices?client_id=10&page=1&page_size=50 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/payments?client_id=10&page=1&page_size=50 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/invoices/123 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/payments/456 -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/invoices/123/payments -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/payments/456/invoices -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/invoices -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/clients/10/payments -H "X-API-Key: <key>"Views (billing):
curl http://127.0.0.1:8844/v1/views/invoices-no-payments -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/views/payments-no-invoices -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/views/invoices-fully-paid -H "X-API-Key: <key>"
curl http://127.0.0.1:8844/v1/views/invoices-not-fully-paid -H "X-API-Key: <key>"Outstanding (billing):
curl "http://127.0.0.1:8844/v1/views/outstanding/invoices?client_id=10" -H "X-API-Key: <key>"
curl "http://127.0.0.1:8844/v1/views/outstanding/invoices?due_in_days=7" -H "X-API-Key: <key>"
curl "http://127.0.0.1:8844/v1/views/outstanding/jas?employee_id=5" -H "X-API-Key: <key>"
curl "http://127.0.0.1:8844/v1/views/outstanding/pos?resource_id=7&overdue=true" -H "X-API-Key: <key>"Outstanding responses include state,days_until_due, and is_overdue where
applicable. Note: due_in_days/overdue are
supported only when the underlying table exposes plan/fact settlement
dates.
Offline vs Internet
- Default bind is
127.0.0.1(offline/local only). - To expose on LAN, set
BIND_HOST=0.0.0.0and add
firewall rules. - For Internet exposure, place behind a VPN or reverse proxy with
TLS.
Notes
- Fee kinds allowed:
per unit,free,flat fee. - Rate limit defaults to
30requests/second per key;
adjust viaRATE_LIMIT_PER_SECOND. - Error responses always include
error.detailswith at
least ahint, and when applicable:entityandidfor not-found errorsrequired_scope/required_rolefor
permission errorsfieldsfor unknown/invalid fields
- Logs are written to
logs/and rotate daily or at 1 MB
(configurable). - For inserts, the API checks SQL Server column existence and ignores
unknown fields. - API may execute loads of commands, with none or little to none UI
confirmations. Use extreme caution when using API calls to delete or
update data in the database. - Always know what you do. If unsure, better consult support team or
your IT department representatives.
Disclaimer
You use the product at your own risk. Advanced International
Translations is not responsible for your data.
Projetex API — Webhooks
Webhooks let external systems react to changes in Projetex in real time. When something changes in the database — a project is created, a job is updated, a payment is recorded — the API sends an HTTP POST to a URL you register. Your server receives the event, processes it, and responds.
This document covers everything: how to enable webhooks, register a receiver, verify signatures, manage subscriptions, and handle common use cases.
How Webhooks Work (Internals)
SQL Server DB
│
│ SQL Server Change Tracking (per-table)
â–¼
Change Tracking poller (runs every CHANGE_TRACKING_POLL_INTERVAL_SECONDS, default 5 s)
│
│ Enqueues change events
â–¼
SQLite outbox queue (db/api_meta.sqlite3)
│
│ Dispatcher reads queue (every WEBHOOK_DISPATCH_INTERVAL_SECONDS, default 2 s)
â–¼
HTTP POST to your receiver URL
│
├─ 2xx → marked "sent", done
└─ non-2xx / timeout → exponential back-off retry (up to WEBHOOK_RETRY_MAX attempts)
Back-off schedule (defaults: base = 5 s, max retries = 10):
| Attempt | Delay before retry |
|---|---|
| 1 | 5 s |
| 2 | 10 s |
| 3 | 20 s |
| 4 | 40 s |
| 5 | 80 s |
| 6 | 160 s |
| 7 | 320 s |
| 8 | 640 s |
| 9 | 1 280 s |
| 10 | 2 560 s |
After the maximum attempts, the delivery is marked
failed and no further retries occur. You can trigger a
manual retry at any time.
Step 0 — Prerequisites
1. Enable webhooks in
.env
WEBHOOKS_ENABLED=true
# Optional tuning (shown with defaults)
WEBHOOK_TIMEOUT_SECONDS=10
WEBHOOK_RETRY_MAX=10
WEBHOOK_RETRY_BASE_SECONDS=5
WEBHOOK_DISPATCH_INTERVAL_SECONDS=2
WEBHOOKS_INCLUDE_DIFFS=false # set true to include field-level before/after diffs
CHANGE_TRACKING_POLL_INTERVAL_SECONDS=5Restart the API after changing .env.
2. SQL Server Change Tracking must be enabled
Change Tracking must be active on the database and on every table you want events from. If Change Tracking is disabled on SQL Server, the poller will log errors and no events will be produced.
To enable it (SQL Server):
-- On the database
ALTER DATABASE YourDatabase
SET CHANGE_TRACKING = ON (CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON);
-- On each table (example)
ALTER TABLE Projects ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS_UPDATED = ON);Step 1 — Discover Available Events
GET /v1/admin/webhooks/events
X-API-Key: pja_your_key_here
Response:
{
"ok": true,
"data": [
"client.created",
"client.updated",
"client.deleted",
"project.created",
"project.updated",
"project.completed",
"project.deleted",
"cjob.created",
"cjob.updated",
"cjob.completed",
"cjob.deleted",
"rjob.created",
"rjob.updated",
"rjob.assigned",
"rjob.completed",
"rjob.deleted",
"ejob.created",
"ejob.updated",
"ejob.deleted",
"ejob.assigned",
"ejob.completed",
"resource.created",
"resource.updated",
"employee.created",
"employee.updated",
"invoice.created",
"invoice.updated",
"invoice.fully_paid",
"invoice.deleted",
"payment.created",
"payment.updated",
"rinvoice.created",
"rinvoice.updated",
"einvoice.created",
"einvoice.updated",
"..."
]
}Event names follow the pattern
<entity>.<action> where action is one of
created, updated, deleted, plus
special lifecycle actions like assigned and
completed for jobs.
Lifecycle Events
Most events are raw CRUD — created,
updated, deleted. In addition, the API emits
lifecycle events when a row update carries specific
business meaning. These are derived from field-level changes detected by
the CT poller and fire regardless of the
WEBHOOKS_INCLUDE_DIFFS setting.
| Event | Trigger condition |
|---|---|
ejob.assigned |
EMP_ID changed to a non-null value |
ejob.completed |
EJOB_ISCOMPLETED flipped to true |
rjob.assigned |
RES_ID changed to a non-null value |
rjob.completed |
RJOB_ISCOMPLETED flipped to true |
cjob.completed |
CJOB_ISCOMPLETED flipped to true |
project.completed |
PROJ_IS_COMPLETED flipped to true |
invoice.fully_paid |
CINV_ASSIGN_TOTAL reached or exceeded
CINV_TOTAL |
Lifecycle events replace the generic .updated event —
when a job is completed, the event is ejob.completed,
not ejob.updated. Subscribe to both if you
want to catch completions regardless of whether the completion happens
via the API or directly in Projetex.
Step 2 — Register a Webhook
POST /v1/admin/webhooks
X-API-Key: pja_your_key_here
Content-Type: application/json
{
"url": "https://your-server.example.com/webhook",
"events": ["project.created", "project.updated", "project.deleted"],
"secret": "my-signing-secret",
"is_active": true,
"description": "Sync projects to CRM"
}
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | HTTPS URL that will receive POST requests |
events |
array of strings | Yes | Event names to subscribe to. Use ["*"] to subscribe to
all events |
secret |
string | No | Signing secret for HMAC-SHA256 signature verification |
is_active |
boolean | No | Default true. Set false to pause delivery
without deleting |
description |
string | No | Human-readable label for this webhook |
Response:
{
"ok": true,
"data": {
"id": 1,
"url": "https://your-server.example.com/webhook",
"events": ["project.created", "project.updated", "project.deleted"],
"secret": "my-signing-secret",
"is_active": true,
"description": "Sync projects to CRM",
"created_at": "2026-04-25T09:00:00"
}
}Save the id — you’ll use it to manage this webhook
later.
Wildcard subscriptions
Subscribe to all events from a single entity:
Subscribe to all events from all entities:
Step 3 — Build Your Receiver
Your receiver must:
- Accept
POSTrequests - Respond with
2xxquickly (underWEBHOOK_TIMEOUT_SECONDS, default 10 s) - Optionally verify the signature header before processing
Python / Flask
import hashlib
import hmac
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "my-signing-secret"
@app.route("/webhook", methods=["POST"])
def webhook():
# 1. Verify signature
sig_header = request.headers.get("X-Webhook-Signature", "")
if WEBHOOK_SECRET and sig_header:
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(),
request.get_data(), # raw bytes — do NOT decode first
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig_header, expected):
abort(403, "Invalid signature")
# 2. Parse event
event = request.get_json()
print(f"Event: {event['event']} Entity ID: {event['entity_id']}")
# 3. Handle it
if event["event"] == "project.created":
sync_project_to_crm(event["data"])
return "", 200 # respond 200 immediately
def sync_project_to_crm(project):
pass # your business logic hereNode.js / Express
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = "my-signing-secret";
// Use raw body for signature verification
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
// 1. Verify signature
const sigHeader = req.headers["x-webhook-signature"] || "";
if (WEBHOOK_SECRET && sigHeader) {
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))) {
return res.status(403).send("Invalid signature");
}
}
// 2. Parse and handle
const event = JSON.parse(req.body);
console.log(`Event: ${event.event} Entity: ${event.entity_id}`);
if (event.event === "project.created") {
syncProjectToCrm(event.data);
}
res.status(200).end();
});
app.listen(3000);PHP
<?php
$secret = 'my-signing-secret';
$rawBody = file_get_contents('php://input');
// 1. Verify signature
$sigHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if ($secret && $sigHeader) {
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $sigHeader)) {
http_response_code(403);
exit('Invalid signature');
}
}
// 2. Parse event
$event = json_decode($rawBody, true);
error_log("Event: {$event['event']} Entity: {$event['entity_id']}");
// 3. Handle
if ($event['event'] === 'project.created') {
syncProjectToCrm($event['data']);
}
http_response_code(200);The Payload Contract
Every webhook POST has this structure:
{
"event": "project.updated",
"entity": "project",
"action": "updated",
"entity_id": 42,
"occurred_at": "2026-04-25T09:12:34.567890",
"idempotency_key": "project.updated:42:1714039954567890",
"data": {
"ProjectID": 42,
"ProjectName": "Annual Report 2026",
"ClientID": 7,
"StatusID": 2,
"...": "..."
}
}Fields:
| Field | Type | Description |
|---|---|---|
event |
string | Full event name, e.g. project.updated |
entity |
string | Entity family name, e.g. project |
action |
string | created, updated, deleted,
assigned, completed |
entity_id |
integer | Primary key of the affected record |
occurred_at |
string (ISO 8601) | UTC timestamp when the change was detected |
idempotency_key |
string | Unique per logical event — use for deduplication |
data |
object or null | Current row data (null for deleted events) |
Delete events
When a record is deleted, data is null and
a deleted key is added:
{
"event": "project.deleted",
"entity": "project",
"action": "deleted",
"entity_id": 42,
"occurred_at": "2026-04-25T09:15:00.000000",
"idempotency_key": "project.deleted:42:1714040100000000",
"data": null,
"deleted": {
"primary_key": 42
}
}Field-level diffs
(WEBHOOKS_INCLUDE_DIFFS=true)
When diffs are enabled, updated events include a
diff key:
{
"event": "project.updated",
"...": "...",
"data": { "ProjectID": 42, "StatusID": 3, "..." : "..." },
"diff": {
"StatusID": { "before": 2, "after": 3 }
}
}HTTP Headers on Every Delivery
| Header | Example value | Description |
|---|---|---|
X-Webhook-Id |
1 |
ID of the webhook registration |
X-Webhook-Event |
project.updated |
Full event name |
X-Webhook-Delivery-Id |
99 |
ID of this specific delivery attempt |
X-Webhook-Idempotency-Key |
project.updated:42:... |
Same as payload field |
X-Webhook-Signature |
sha256=a1b2c3... |
HMAC-SHA256 of raw body bytes (only if secret is
set) |
Content-Type |
application/json |
Always JSON |
Signature Verification
The signature header lets your receiver prove the request came from the API and was not tampered with.
Algorithm:
sha256=HMAC-SHA256(secret, raw_body_bytes).hexdigest()
Critical rules:
- Compute the HMAC over the raw request body bytes — not the decoded JSON, not re-serialized JSON.
- Use a constant-time comparison
(
hmac.compare_digestin Python,crypto.timingSafeEqualin Node) to prevent timing attacks. - If
secretwas left empty when registering the webhook, noX-Webhook-Signatureheader is sent.
import hashlib, hmac
def verify(secret: str, raw_body: bytes, sig_header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig_header, expected)Sending a Test Event
After registering a webhook, send a synthetic ping to verify your receiver is working:
POST /v1/admin/webhooks/test
X-API-Key: pja_your_key_here
Content-Type: application/json
{
"webhook_id": 1,
"event": "project.created"
}
The API delivers a fake payload to your registered URL immediately. Check the response and your receiver logs.
Managing Webhooks
List all webhooks
GET /v1/admin/webhooks
X-API-Key: pja_your_key_here
Get one webhook
GET /v1/admin/webhooks/1
X-API-Key: pja_your_key_here
Update a webhook (PATCH)
PATCH /v1/admin/webhooks/1
X-API-Key: pja_your_key_here
Content-Type: application/json
{
"events": ["project.*", "client.*"],
"is_active": true,
"description": "Updated description"
}
Only send fields you want to change. All fields are optional.
Pause delivery (without deleting)
PATCH /v1/admin/webhooks/1
X-API-Key: pja_your_key_here
Content-Type: application/json
{ "is_active": false }
Events generated while paused are not queued and will not be delivered retroactively.
Resume delivery
PATCH /v1/admin/webhooks/1
X-API-Key: pja_your_key_here
Content-Type: application/json
{ "is_active": true }
Delete a webhook
DELETE /v1/admin/webhooks/1
X-API-Key: pja_your_key_here
Returns 204 No Content. Existing delivery records are
retained for audit purposes.
Monitoring Deliveries
All recent deliveries (all webhooks)
GET /v1/admin/webhooks/deliveries?limit=50
X-API-Key: pja_your_key_here
Deliveries for one webhook
GET /v1/admin/webhooks/1/deliveries?limit=50
X-API-Key: pja_your_key_here
Delivery record fields:
| Field | Description |
|---|---|
id |
Delivery attempt ID |
webhook_id |
Which webhook registration |
event |
Event name |
status |
pending, sent, failed |
response_status |
HTTP status code your server returned |
attempt |
Attempt number (1 = first try) |
next_attempt_at |
When the next retry will fire (null if sent/failed) |
created_at |
When the delivery was first enqueued |
sent_at |
When the last attempt was made |
Queue health
GET /v1/admin/webhooks/health
X-API-Key: pja_your_key_here
Returns counts of pending, sent, and
failed deliveries. Useful for dashboards and alerting.
Retrying Failed Deliveries
Retry one delivery
POST /v1/admin/webhooks/1/deliveries/99/retry
X-API-Key: pja_your_key_here
Resets the delivery to pending and dispatches it
immediately on the next dispatcher cycle.
Bulk replay by filter
Re-enqueue all events matching a time range and optional event/entity filter:
POST /v1/admin/webhooks/replay
X-API-Key: pja_your_key_here
Content-Type: application/json
{
"webhook_id": 1,
"since": "2026-04-25T00:00:00",
"until": "2026-04-25T23:59:59",
"event": "project.updated"
}
All matched deliveries are re-queued for delivery.
Common Use Cases
1. Slack notification when a project is created
import hashlib, hmac, json
from flask import Flask, request, abort
import urllib.request
app = Flask(__name__)
SLACK_URL = "https://hooks.slack.com/services/..."
SECRET = "my-signing-secret"
@app.post("/webhook")
def webhook():
raw = request.get_data()
sig = request.headers.get("X-Webhook-Signature", "")
expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(403)
ev = request.get_json()
if ev["event"] == "project.created":
p = ev["data"]
msg = f":briefcase: New project *{p['ProjectName']}* (ID {p['ProjectID']}) created"
req = urllib.request.Request(
SLACK_URL,
data=json.dumps({"text": msg}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req)
return "", 2002. Sync clients to an external CRM
@app.post("/webhook")
def webhook():
ev = request.get_json()
entity, action = ev["event"].split(".")
if entity == "client":
if action in ("created", "updated"):
crm_upsert_client(ev["data"])
elif action == "deleted":
crm_delete_client(ev["deleted"]["primary_key"])
return "", 2003. Post invoices to accounting software
INVOICE_EVENTS = {"cinvoice.created", "cinvoice.updated", "cpayment.created"}
@app.post("/webhook")
def webhook():
ev = request.get_json()
if ev["event"] in INVOICE_EVENTS:
accounting_sync(ev)
return "", 2004. Notify freelancer when a job is assigned
@app.post("/webhook")
def webhook():
ev = request.get_json()
if ev["event"] == "rjob.assigned":
job = ev["data"]
send_email(
to=lookup_freelancer_email(job["ResourceID"]),
subject=f"New job assigned: {job['JobName']}",
body=f"Project {job['ProjectID']}, due {job['DeadlineDate']}.",
)
return "", 2005. Real-time dashboard via Server-Sent Events
import queue, threading
from flask import Flask, Response, request
app = Flask(__name__)
_bus = [] # list of subscriber queues
@app.post("/webhook")
def ingest():
ev = request.get_json()
for q in list(_bus):
q.put(ev)
return "", 200
@app.get("/stream")
def stream():
q = queue.Queue()
_bus.append(q)
def generate():
try:
while True:
ev = q.get()
yield f"data: {json.dumps(ev)}\n\n"
finally:
_bus.remove(q)
return Response(generate(), mimetype="text/event-stream")6.
Field-level audit log (requires
WEBHOOKS_INCLUDE_DIFFS=true)
@app.post("/webhook")
def webhook():
ev = request.get_json()
diff = ev.get("diff")
if diff:
for field, change in diff.items():
audit_log.write(
entity=ev["entity"],
entity_id=ev["entity_id"],
field=field,
before=change["before"],
after=change["after"],
occurred_at=ev["occurred_at"],
)
return "", 200Deduplication
Each delivery carries a stable idempotency_key (also in
X-Webhook-Idempotency-Key). Under retry or replay, the same
logical event always has the same key. Store seen keys in Redis or a DB
table with a TTL of 24 hours:
seen = set() # replace with Redis SETNX in production
@app.post("/webhook")
def webhook():
ev = request.get_json()
key = ev["idempotency_key"]
if key in seen:
return "", 200 # already processed — drop silently
seen.add(key)
process(ev)
return "", 200Full API Reference
| Method | Path | Description |
|---|---|---|
GET |
/v1/admin/webhooks/events |
List all supported event names |
GET |
/v1/admin/webhooks |
List all registered webhooks |
POST |
/v1/admin/webhooks |
Register a new webhook |
GET |
/v1/admin/webhooks/{id} |
Get one webhook |
PATCH |
/v1/admin/webhooks/{id} |
Update a webhook (partial update) |
DELETE |
/v1/admin/webhooks/{id} |
Delete a webhook |
POST |
/v1/admin/webhooks/test |
Send a test event to a webhook |
GET |
/v1/admin/webhooks/deliveries |
List recent deliveries (all webhooks) |
GET |
/v1/admin/webhooks/{id}/deliveries |
List deliveries for one webhook |
POST |
/v1/admin/webhooks/{id}/deliveries/{dlv_id}/retry |
Retry one failed delivery |
POST |
/v1/admin/webhooks/replay |
Bulk re-enqueue deliveries by filter |
GET |
/v1/admin/webhooks/health |
Queue health counts |
All endpoints require X-API-Key with admin
privileges.
Troubleshooting
Events are not arriving
- Check
WEBHOOKS_ENABLED=truein.envand restart the API. - Confirm SQL Server Change Tracking is enabled on the database and relevant tables.
- Check the queue health endpoint — are events being enqueued?
- Look at the delivery log for that webhook: is
statusshowingfailed?
Signature mismatch
- Make sure you compute the HMAC over the raw request body bytes, not decoded/re-encoded JSON.
- Verify the
secretin the webhook registration matches your receiver’s secret exactly (case-sensitive, no trailing spaces). - Use
hmac.compare_digest(constant-time) rather than==.
Duplicate events
- Use the
idempotency_keyfield to deduplicate. Each logical event has one stable key across retries and replays.
data is null on an update event
- The row was deleted between the change being detected and the delivery being dispatched. Treat it as a delete.
Delivery is stuck in pending
- The dispatcher thread runs every
WEBHOOK_DISPATCH_INTERVAL_SECONDS(default 2 s). If the API was restarted while a delivery was in flight, it may need a manual retry. - Use
POST /v1/admin/webhooks/{id}/deliveries/{dlv_id}/retryto force immediate re-dispatch.