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

  1. In QWTN, go to Settings → API access and create a key (owner or admin). Copy it: it is shown once.
  2. 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.

ScopeAllows
*Everything below
notes:readRead waste transfer notes and PDFs
notes:writeCreate draft transfer notes
clients:readRead clients
clients:writeCreate and update clients
sites:readRead sites
collections:readRead collections
collections:writeCreate collections
containers:readRead containers
invoices:readRead invoices
quotes:readRead quotes
consignments:readRead hazardous consignment notes
weighbridge:readRead weighbridge tickets
weighbridge:writeCreate 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.

EventSent when
note.signedA transferor or transferee signature was captured on a waste transfer note.
note.completedA transfer note became complete (or a season ticket became valid).
collection.completedA driver or office user marked a collection completed.
invoice.sentAn invoice was sent to the customer for the first time.
invoice.paidAn invoice was marked paid (manually or by payment link).
quote.acceptedA customer accepted a quote.
consignment.issuedA hazardous waste consignment note was issued.
compliance.expiringA 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 Gone disables 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_at and 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