Developers
Plug QWTN into the rest of your business
A REST API for transfer notes, jobs, clients and invoices; signed webhooks when things change; payment links and CSV exports built in.
- RESTJSON over HTTPS, cursor pagination
- 9Resources, read and write
- 8Webhook event types
- 3.1OpenAPI spec, generated from the code
Quick start
Your first request in two minutes
- In QWTN, go to Settings → API access and create a key (owner or admin). Copy it: it is shown once.
- Send it as a bearer token. Every response is JSON; every list is newest first.
curl https://qwtn.co.uk/api/v1/notes?limit=2 \
-H "Authorization: Bearer $QWTN_API_KEY"{
"object": "list",
"data": [{ "id": "0b15c088-…", "object": "note", "reference": "WTN-202610-F2BFBA", "status": "complete", … }],
"has_more": true,
"next_cursor": "eyJjIjoiMjAyNi0xMC0xMlQxMDo…"
}Authentication and scopes
Keys look like qwtn_live_XXXXXXXX_…. The first part (the prefix) is shown in the app so you can tell keys apart; we store only a SHA-256 hash of the rest. Keys belong to the team, can expire, and can be revoked instantly. Requests with a missing, revoked or expired key get 401; a key without the right scope gets 403 insufficient_scope.
| Scope | Allows |
|---|---|
| * | Everything below |
| notes:read | Read waste transfer notes and PDFs |
| notes:write | Create draft transfer notes |
| clients:read | Read clients |
| clients:write | Create and update clients |
| sites:read | Read sites |
| collections:read | Read collections |
| collections:write | Create collections |
| containers:read | Read containers |
| invoices:read | Read invoices |
| quotes:read | Read quotes |
| consignments:read | Read hazardous consignment notes |
| weighbridge:read | Read weighbridge tickets |
| weighbridge:write | Create weighbridge tickets |
A :write scope includes :read for the same records. GET /api/v1/me tells you which team and scopes a key has.
Requests and errors
Send JSON bodies with Content-Type: application/json. Unknown fields are rejected so typos never pass silently. Dates are YYYY-MM-DD, timestamps ISO 8601 UTC, money in pounds as numbers. Every response carries an X-Request-Id; quote it if you contact us.
Errors always have the same shape. Branch on code, not the message:
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"type": "invalid_request_error",
"code": "validation_failed",
"message": "The request body has invalid fields",
"request_id": "req_8b0153f6deef40de9fb0",
"details": [{ "field": "ewc_codes", "message": "Required" }]
}
}Codes: unauthenticated, invalid_api_key, insufficient_scope, not_found, validation_failed, invalid_cursor, rate_limited, idempotency_conflict, idempotency_in_progress, internal_error.
Pagination
Lists return up to limit items (1–100, default 25), newest first. When has_more is true, pass next_cursor back as cursor. Cursors are stable while new records arrive, so you never skip or repeat a row. For incremental sync of notes use updated_after.
let cursor = null
do {
const url = new URL('https://qwtn.co.uk/api/v1/collections')
url.searchParams.set('status', 'completed')
if (cursor) url.searchParams.set('cursor', cursor)
const page = await fetch(url, { headers: { Authorization: `Bearer ${process.env.QWTN_API_KEY}` } }).then((r) => r.json())
for (const job of page.data) handle(job)
cursor = page.next_cursor
} while (cursor)Idempotency
Send an Idempotency-Key header (any unique string, e.g. a UUID) on every POST and PATCH. If the network drops and you retry with the same key within 24 hours, you get the original response back with Idempotent-Replayed: true instead of a duplicate note or ticket. Reusing a key with a different body returns 422 idempotency_conflict.
curl -X POST https://qwtn.co.uk/api/v1/notes \
-H "Authorization: Bearer $QWTN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6c0f2a1e-job-8812" \
-d '{
"waste_description": "Mixed construction and demolition waste",
"ewc_codes": ["17 09 04"],
"client_id": "e913a8a6-ffe2-47c4-9686-f25938ae9154",
"weight_kg": 1200,
"transfer_date": "2026-10-14"
}'Rate limits
120 requests per minute per key. Over the limit you get 429 rate_limited with a Retry-After header in seconds. Back off and retry; bulk jobs should page through lists rather than fetching records one by one.
Endpoints
Base URL https://qwtn.co.uk. Full field-level detail is in the OpenAPI 3.1 document (import it into Postman, Insomnia or a code generator).
Waste transfer notes
notes:read- GET
/api/v1/notes - GET
/api/v1/notes/{id} - POST
/api/v1/notes - GET
/api/v1/notes/{id}/pdf
Filters: statusclient_idupdated_aftercreated_aftercreated_before
Clients
clients:read- GET
/api/v1/clients - GET
/api/v1/clients/{id} - POST
/api/v1/clients - PATCH
/api/v1/clients/{id}
Filters: emailpostcodecreated_aftercreated_before
Sites
sites:read- GET
/api/v1/sites - GET
/api/v1/sites/{id}
Filters: site_typecreated_aftercreated_before
Collections
collections:read- GET
/api/v1/collections - GET
/api/v1/collections/{id} - POST
/api/v1/collections
Filters: statusclient_idscheduled_fromscheduled_tocreated_aftercreated_before
Containers
containers:read- GET
/api/v1/containers - GET
/api/v1/containers/{id}
Filters: statusclient_idcreated_aftercreated_before
Invoices
invoices:read- GET
/api/v1/invoices - GET
/api/v1/invoices/{id}
Filters: statusclient_idissued_fromcreated_aftercreated_before
Quotes
quotes:read- GET
/api/v1/quotes - GET
/api/v1/quotes/{id}
Filters: statusclient_idcreated_aftercreated_before
Hazardous waste consignment notes
consignments:read- GET
/api/v1/consignment-notes - GET
/api/v1/consignment-notes/{id}
Filters: statusclient_idcreated_aftercreated_before
Weighbridge tickets
weighbridge:read- GET
/api/v1/weighbridge-tickets - GET
/api/v1/weighbridge-tickets/{id} - POST
/api/v1/weighbridge-tickets
Filters: ticket_fromvehicle_regcreated_aftercreated_before
Webhooks
Add HTTPS endpoints in Settings → Webhooks and choose the events to receive. Each event is a JSON POST whose data.object is the record exactly as the REST API returns it.
| Event | Sent when |
|---|---|
| note.signed | A transferor or transferee signature was captured on a waste transfer note. |
| note.completed | A transfer note became complete (or a season ticket became valid). |
| collection.completed | A driver or office user marked a collection completed. |
| invoice.sent | An invoice was sent to the customer for the first time. |
| invoice.paid | An invoice was marked paid (manually or by payment link). |
| quote.accepted | A customer accepted a quote. |
| consignment.issued | A hazardous waste consignment note was issued. |
| compliance.expiring | A vehicle MOT/tax/insurance, site permit or driver licence/CPC is within 30 days of expiry (sent at 30, 7 and 0 days). |
POST /your/endpoint
QWTN-Signature: t=1760262000,v1=5f2b…c9
QWTN-Event-Id: evt_6a1f…
QWTN-Event-Type: invoice.paid
{ "id": "evt_6a1f…", "object": "event", "type": "invoice.paid", "api_version": "2026-10-01",
"created_at": "2026-10-12T09:40:00Z", "data": { "object": { "id": "…", "object": "invoice", "status": "paid", … } } }Verify the signature
Compute HMAC-SHA256 of `${t}.${rawBody}` with your endpoint secret and compare it, in constant time, with any v1 value. Reject timestamps more than five minutes old. During a secret rotation the header carries two v1 values for 24 hours.
import crypto from 'node:crypto'
app.post('/webhooks/qwtn', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('QWTN-Signature') ?? ''
const t = Number(/t=(\d+)/.exec(header)?.[1])
const expected = crypto.createHmac('sha256', process.env.QWTN_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest('hex')
const ok = Math.abs(Date.now() / 1000 - t) < 300 &&
[...header.matchAll(/v1=([0-9a-f]{64})/g)].some(([, sig]) =>
crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex')))
if (!ok) return res.status(400).end()
const event = JSON.parse(req.body)
// dedupe on event.id, then queue your work and reply fast
res.sendStatus(204)
})Delivery and retries
- Reply with any 2xx within 10 seconds. Do slow work after replying.
- Anything else is retried after 1m, 5m, 30m, 2h, 6h, 12h, 24h, 24h (with jitter), then marked failed. Retries are at-least-once: dedupe on
id. - Replying
410 Gonedisables the endpoint; 15 events in a row that exhaust their retries also disable it. - The delivery log in Settings shows every attempt, payload and response, and lets you replay any event or send a test
ping. - Order is not guaranteed. Use
created_atand fetch the record from the API if you need the latest state.
Built-in integrations
- Stripe and GoCardless: pay-now links on invoices that settle into your own account; invoices are marked paid automatically.
- CSV import and full export: clients, client sites, containers and vehicles in; every record type out as a ZIP of CSVs.
Versioning and support
This is API version 2026-10-01 (sent as the QWTN-Version response header). We add fields and endpoints without notice, so ignore fields you do not recognise. Breaking changes get a new version and at least six months’ notice by email to account owners. Questions: support@qwtn.co.uk.
Ready to connect your systems?
Create a key in Settings → API access, or start a free account first.
No sign-up needed for one-off notes · 2-year cloud storage on paid plans · DEFRA 2026 ready