Partner API, v1
Put a real queue inside your product.
Read a venue's live queue, book slots for your customers, and get a signed webhook the moment a ticket changes state. OAuth, REST, JSON, and a mock server you can run before we have met.
Quickstart
Run the mock server, get a token, read a queue. Nothing to sign up for.
# 1. run the mock (Node 18+, no dependencies)
node eqms-api-mock.js
# 2. the venue admin authorises your app. The mock approves instantly.
curl -s "http://localhost:8787/oauth/authorize?client_id=app_demo\
&venue_id=venue_sss_qc&scope=venue:read%20queue:read%20bookings:write"
# 3. exchange the code for a token
curl -s -X POST http://localhost:8787/oauth/token \
-d grant_type=authorization_code \
-d code=code_... -d client_id=app_demo -d client_secret=secret_demo
# 4. read the queue
curl -s http://localhost:8787/v1/venues/venue_sss_qc/queue \
-H "Authorization: Bearer at_..."
{
"venue_id": "venue_sss_qc",
"as_of": "2026-09-17T10:14:22.031Z",
"open": true,
"lanes": [
{ "lane": "GENERAL", "waiting": 34, "now_serving": ["G-1524"],
"estimated_wait_seconds": 2280, "confidence": "normal" },
{ "lane": "PRIORITY", "waiting": 3, "now_serving": ["P-41"],
"estimated_wait_seconds": 420, "confidence": "normal" }
]
}
Every queue response is stamped. Show that timestamp next to any number you take from it. A wait time shown as live when it is ten minutes old sends someone to a branch on bad information, and they blame the venue rather than your cache.
Sandbox and mock server
Two ways to develop without touching a real branch.
| Environment | Base URL | What it is |
|---|---|---|
| Mock | http://localhost:8787 |
A single file you run yourself. Four seeded venues, one per sector, with a queue that moves. In-memory: restart and it resets. |
| Sandbox | sandbox.api.e-qms.io |
Hosted, same contract, credentials issued without a sales conversation. |
| Production | api.e-qms.io |
Real venues. Each one authorises your app itself. |
Mock credentials are app_demo / secret_demo. Venues are
venue_sss_qc, venue_clinic_bt,
venue_lgu_psg and venue_bank_mkt.
GET / prints the quickstart, and GET /_deliveries shows
what the mock tried to POST to your webhook endpoint, which helps while you are
still getting signature checking right.
Authentication
OAuth 2.0 authorisation code with PKCE. Three things are worth understanding before you write the code.
A venue grants access, not us
You send the venue's administrator to our authorisation page. They see your app name, their venue, and every scope you asked for. They approve, and you get tokens for that one venue.
If you serve forty clinics, you hold forty authorisations. That is deliberate. One leaked token exposes one venue, and a clinic that leaves you can revoke its own access without anybody else noticing.
Tokens
| Token | Lifetime | Can do |
|---|---|---|
| Access | 1 hour | Everything its scopes allow, for its venue |
| Refresh | Until revoked | Mint new access tokens |
client_credentials | 1 hour | Only GET /venues, to list who has authorised you. It cannot
read a queue. |
Scopes
| Scope | Grants |
|---|---|
venue:read | Profile, hours, services, documents |
queue:read | Live queue and ticket status |
bookings:write | Create and cancel bookings |
tickets:write | Issue walk-in tickets, where the venue allows it |
events:subscribe | Register webhook endpoints |
analytics:read | Aggregate statistics |
Ask only for what you use. A venue administrator reading a consent screen that requests everything is a venue administrator who says no.
A venue can revoke your app from its Integrations page at any moment, and both
tokens stop working within seconds. Handle 401 by re-authorising,
not by retrying in a loop. That page also shows when your app last called, so a
forgotten integration quietly reading their data every minute is visible to them.
Endpoint reference
Base path /v1. All responses are JSON. The full machine-readable
contract is in eqms-openapi.yaml.
| Endpoint | Scope | Notes |
|---|---|---|
GET/venues |
any | Venues that authorised your app |
GET/venues/{id} |
venue:read | Profile, hours, timezone, capabilities |
GET/venues/{id}/services |
venue:read |
Includes the required documents. Show these before the customer travels. |
GET/venues/{id}/queue |
queue:read | Live state. Cached 5s, rate limited. |
GET/venues/{id}/availability |
venue:read | Bookable slots for a service and date |
POST/venues/{id}/bookings |
bookings:write | Requires Idempotency-Key |
GET/bookings/{id} |
bookings:write | Read one back |
DELETE/bookings/{id} |
bookings:write | Cancel and return the slot |
POST/venues/{id}/tickets |
tickets:write | Walk-in. Off unless the venue enables it. |
GET/tickets/{id} |
queue:read | State and advisory position |
POST/tickets/{id}/notes |
bookings:write | Attach your own case reference |
POST/venues/{id}/webhooks |
events:subscribe | Register an endpoint |
GET/venues/{id}/stats |
analytics:read | Aggregates including the wasted-trip rate |
Booking a slot
curl -X POST http://localhost:8787/v1/venues/venue_sss_qc/bookings \
-H "Authorization: Bearer at_..." \
-H "Idempotency-Key: 8f14e45f-ea6b-4f31-9d4e-1b0f9c2a7e33" \
-H "Content-Type: application/json" \
-d '{
"service_id": "svc_loan",
"slot_start": "2026-09-18T09:30:00+08:00",
"customer_ref": "CRM-88213",
"priority_claimed": false
}'
Not optional, not best practice. Without it, your HTTP client's automatic retry
on a timeout books the same customer twice and the venue loses a slot it cannot
sell. Send a UUID per logical booking. Retrying with the same key returns the
original booking for 24 hours; reusing it with a different body is a
409.
Late arrival
Every booking carries grace_minutes. Arrive later than that past the
slot and the reservation is released. The customer is not turned away. They
join their lane at their actual arrival position, behind people already waiting.
Say this in your own interface; a customer who thinks a booking is a guaranteed
time will be angry at a counter your software sent them to.
Checking a ticket
position moves. Walk-ins arrive, priority runs on separate capacity,
and someone ahead may be sent home for a missing document. Render it as an
estimate. "About 4 ahead of you" survives contact with reality;
"You are 4th" generates complaints the venue has to answer.
Webhooks
If you are polling, you have missed the point. Webhooks are unlimited and free; the queue endpoint is cached and rate limited.
POST https://your-app.example/eqms
X-EQMS-Event: ticket.completed
X-EQMS-Delivery: evt_01J9Z8X7QK
X-EQMS-Signature: t=1758103482,v1=5257a869e7ec...
Verify every delivery
HMAC-SHA256 over the exact string {timestamp}.{raw body} using your
endpoint's signing secret. Compare with a constant-time function. Reject anything
whose timestamp is more than five minutes old.
const [, ts, sig] = req.headers['x-eqms-signature']
.match(/t=(\d+),v1=([0-9a-f]+)/);
const expected = crypto.createHmac('sha256', SIGNING_SECRET)
.update(`${ts}.${rawBody}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return 401;
if (Math.abs(Date.now()/1000 - ts) > 300) return 401; // replay
Delivery is at-least-once. Use id as an idempotency key and discard
duplicates. They are normal, not a fault. Acknowledge with any 2xx within five
seconds and do your real work afterwards; a slow endpoint gets retried and you
will see the same event again.
Events
| Event | When |
|---|---|
booking.created | A slot is reserved |
booking.cancelled | Cancelled by anyone |
booking.expired | Grace window passed without arrival |
ticket.issued | A number was taken |
ticket.called | Called to a window. Carries the window. |
ticket.serving | Service started |
ticket.completed | Finished successfully |
ticket.incomplete |
The visit failed. Carries reason, usually a missing
document. |
ticket.referred | Sent to another service or branch |
ticket.abandoned | Left without being served |
ticket.incomplete is the one to build on. It tells you which of your
own pre-visit instructions is failing, which is something you can fix and the
venue cannot.
Tolerate event types you do not recognise and fields you did not expect. New ones are added within v1 without warning, and an integration that throws on an unknown type will break on a Tuesday for no reason you can see.
What the API will not do
Some things are missing on purpose. These are not permissions you can negotiate
into a contract, and the endpoints return 501 rather than
404 so you know it is a decision rather than a gap.
| Not available | Why |
|---|---|
| GONECall the next customer | Calling is a physical act. A clerk looks up, sees the previous person has actually left the window, and presses Call. Your server cannot know that. Remote calling announces numbers to an empty counter. |
| GONEReorder the queue | Position is derived from an append-only event log under rules the venue set. That is what makes it defensible to a regulator. An external system moving people breaks the audit trail of a client you do not control. |
| GONEGrant priority status | You may declare a claim with priority_claimed. It is
verified against physical ID at the counter, exactly as a walk-in's claim
is. If software could grant it, the lane that exists for senior citizens
and people with disability becomes the fast lane for whoever integrates
best. |
| GONERead staff performance | A raw per-person score is misleading without case-mix adjustment, and you have no context to interpret one. Venue and service aggregates are available; people are not. |
If your product needs one of these, the answer is not a bigger contract. Talk to us about what you are actually trying to achieve, because there is usually a way to get it that does not involve reaching into someone else's queue.
Personal data
Send us as little as possible. customer_ref is your identifier,
a string that means something in your system. Not a name, not an address.
There is no field for it and a 12-digit value in customer_ref is
rejected with 422. Under the Data Privacy Act the venue is the
personal information controller for its queue data. The less that crosses this
boundary, the smaller everyone's exposure and the shorter your customer's
security review.
A phone number is accepted only in notify.msisdn, only where the
venue has enabled notifications, and only for that booking's alerts.
Errors
RFC 9457 problem details, application/problem+json. Branch on
type, never on title, which may be reworded.
{
"type": "https://developers.e-qms.io/errors/slot-unavailable",
"title": "That slot filled",
"status": 409,
"detail": "Call availability again and offer the customer another time.",
"request_id": "req_4f2a91c4b7d3"
}
| Status | Means |
|---|---|
400 | Malformed, or a required header is missing |
401 | Token missing, expired or revoked. Re-authorise. |
403 | Wrong venue, missing scope, or the venue disabled it |
409 | Slot filled, idempotency key reused, already cancelled |
422 | Well-formed but not actionable |
429 | Rate limited. Read Retry-After. |
501 | Deliberately not offered. See above. |
Quote request_id in any support request. It is how we find your call
in our logs.
Rate limits
Per app, per venue. Every response carries a RateLimit header.
| What | Limit |
|---|---|
GET /queue | 120 per minute, cached 5s |
| Other reads | 600 per minute |
| Writes | 120 per minute |
| Webhook deliveries | Unlimited, and free |
The asymmetry is the point. Our cost has to scale with the number of venues, not with how often you ask. Forty venues polled every second is 3.4 million requests a day for data that changed a few hundred times, and a business model that does not survive it.
Versioning
The version is in the path. Inside /v1 we only add: new optional
fields, new endpoints, new event types. Your client must tolerate all three.
A breaking change means /v2, and /v1 keeps working for
twelve months with a deprecation header on every response for the last six.
If you have shipped to forty clinics you cannot migrate in a fortnight, and we
would rather you integrated once than twice.
developers@e-qms.io. If you are building something the API does not quite support, say so before you work around it. The workaround usually becomes the thing that breaks.