Projetex Automation Hub Documentation

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

GroupScopeEntities 
Clientsclients:*CLIENTS 
Client Accountsclients:*CCONTACTS, CACCOUNTS, CACCMANAGERS, CINFO 
Client Jobscjobs:*CJOBS 
Client Quotesbilling:*CMULTIQUOTES, CMULTIQUOTEITEMS, CQUOTESLOG 
Client Pricingbilling:*CPRICES, CDISCOUNTDEFS, CTAXDEFS, CSERVICEDEFS, CUNITDEFS 
Client Billingbilling:*CINVOICES, CPAYMENTS, CFINLINKS, CREFUNDS, CN_INVOICES,
CN_FINLINKS
 
Projectsprojects:*PROJECTS 
Project Extensionsprojects:*PINFO, PTEAM, TASKS, TASK_CODES, TASK_ROLES, JA 
Corporate Jobsejobs:*EJOBS 
Freelance Jobsrjobs:*RJOBS 
Resourcesresources:*RESOURCES, RINFO, RLANG_PAIRS, RSOFTWARE 
Resource Financialsbilling:* / resources:*RINVOICES, RPAYMENTS, RFINLINKS, RQUOTES, RPRICES, RDISCOUNTDEFS,
RTAXDEFS
 
Employeesemployees:*EMPLOYEES, EINFO, ELANG_PAIRS, ESOFTWARE, ERANKS, ELEVELS 
Employee Financialsbilling:* / employees:*EPAYMENTS, EFINLINKS, EPRICES 
Global Rosterprojects:read (read), admin:write
(write)
LANGS, LANGS_ALL, COUNTRIES, EXCHRATES, SOFTWARE, SOFTWARE_TOOLS,
TAXES, SALUTATIONS, PMETHODS, DISCOUNTS, UNITRATIOS
 
Company Settingsadmin:writeAITCOMPANY, ASETTINGS, ATYPES, ASUBTYPES, AITUSERS,
EMAILTEMPLATES, ALERTS, INFSTAT
 
Lookupsprojects:readSERVICES, SERVICEGROUPS, UNITS, CURRENCIES, BILLING_STATES, custom
fields
 
ViewsvariesReporting views, billing, outstanding 
AutomationanyRecipient policy, resolution, snapshot hydration 
Admin*API keys, webhooks, backup 
ProceduresvariesWhitelisted stored procedures 

Requirements

  • Projetex 5D Server

Create API Key for a
Projetex User

Non-admin keys must be bound to an existing
AIT$USERS.AIT$USER_ID.

Admin keys may omit --ait-user-id (they use
-1 for DB context), but non-admin keys without
ait_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=3600

The installer writes these values automatically during setup. You
should not need to edit them manually.

Behavior summary

Key typeWC_SUBSCRIPTION_ENABLEDResult
AdminanyAlways allowed
Non-admin1License 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

  1. Projetex 5D detection — checks for the server
    installation and verifies the SQL Server instance is reachable; exits
    with a clear message if not found.
  2. License activation page — prompts for the license
    key from the customer’s purchase confirmation email; activates the seat
    via the license server.
  3. Windows service — installs and configures the API
    as a Windows service (via NSSM, credits to https://nssm.cc/,
    auto-start).
  4. 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.
  5. 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/write
  • Accountant: clients:read,
    projects:read, jobs:read,
    billing:*
  • CorporateExpert:
    ejobs:read/write_status_only
  • FreelanceExpert:
    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 to
EMP_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=1

Create 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 from
GET /v1/admin/webhooks/events – family wildcard:
<family>.* where family exists – global wildcard:
* – unknown events are rejected with 400 and
error.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.deletedrjob.created,
rjob.updated, rjob.assigned,
rjob.completed, rjob.deleted
invoice.created, invoice.updated,
invoice.fully_paid, invoice.deleted
payment.created, payment.updated,
payment.deletedpo.created,
po.updated, po.deleted
ja.created, ja.updated,
ja.deletedccontact.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.updatedentity_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 with
strategy, 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_typedata (canonical entity payload) –
changes (empty object when unavailable) –
meta.source, meta.occurred_at, optional
meta.change_tracking_version,
meta.idempotency_key

Recipient policy enforcement: – Unknown/forbidden groups return
400 with actionable error.details. –
ejob families reject client and
client_contact. – rjob families reject
employee.

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 pass
currency_code (ISO 4217) or currency_id, and
country_name or country_id. – Custom fields
like AIT$CUSTOMF000034 are validated against
AIT$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 random
confirm_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 with
resource_id. – You can still use
PUT /v1/rjobs/{rjob_id} with resource_id for
assignment. – Unassign via
POST /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 with
employee_id. – You can still use
PUT /v1/ejobs/{ejob_id} with employee_id for
assignment. – Unassign via
POST /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 fit
J-<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.0 and 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 30 requests/second per key;
    adjust via RATE_LIMIT_PER_SECOND.
  • Error responses always include error.details with at
    least a hint, and when applicable:
    • entity and id for not-found errors
    • required_scope / required_role for
      permission errors
    • fields for 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=5

Restart 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:

{ "events": ["project.*"] }

Subscribe to all events from all entities:

{ "events": ["*"] }

Step 3 — Build Your Receiver

Your receiver must:

  1. Accept POST requests
  2. Respond with 2xx quickly (under WEBHOOK_TIMEOUT_SECONDS, default 10 s)
  3. 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 here

Node.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_digest in Python, crypto.timingSafeEqual in Node) to prevent timing attacks.
  • If secret was left empty when registering the webhook, no X-Webhook-Signature header 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 "", 200

2. 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 "", 200

3. 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 "", 200

4. 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 "", 200

5. 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 "", 200

Deduplication

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 "", 200

Full 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=true in .env and 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 status showing failed?

Signature mismatch

  • Make sure you compute the HMAC over the raw request body bytes, not decoded/re-encoded JSON.
  • Verify the secret in 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_key field 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}/retry to force immediate re-dispatch.
Shopping cart0
There are no products in the cart!
Continue shopping
0