Polling for orders

The Orders API is pull-based: DuckHub does not send webhooks. Your integration periodically asks for what changed — one cheap request per interval, no public endpoint on your side, and missed polls heal themselves on the next run.

The updatedSince cursor#

GET /v1/orders?updatedSince=... returns orders whose updatedAt is at/after the given time, sorted by updatedAt ascending. That makes updatedAt a resumable cursor:

  1. Keep a persisted cursor, starting from "now" (or how far back you want to backfill).
  2. Each cycle, request updatedSince=<cursor> and page through the results (limit up to 100, then page=2, ... while page < totalPages).
  3. Process each order: new ones (never seen id) and status changes (compare status with what you stored).
  4. Advance the cursor to the largest updatedAt you received and persist it.

Because a status change bumps updatedAt, the same query surfaces both brand-new orders and updates to ones you already imported — including cancellations.

Cursor rules

Always send the cursor with an explicit timezone (...Z) — naive timestamps are rejected with 400. The window is inclusive, so the order whose updatedAt equals your cursor is returned again — dedupe by id + updatedAt instead of bumping the cursor by a second (an order updated within that same second would be lost).

  • Every 15–30 seconds gives kitchen-speed reactions and stays far below the rate limits (one venue polling at 20s ≈ 0.05 req/s).
  • On 429 or 5xx, back off exponentially and keep the cursor unchanged — you'll catch up on the next successful poll.
  • Confirm accepted orders promptly via PATCH /v1/orders/:id/status so staff see progress in the dashboard.

Example polling loop#

javascript
const BASE = 'https://api.duck-hub.com/v1'
const HEADERS = { Authorization: 'Bearer dk_live_your_api_key' }
 
let cursor = await loadCursor() // e.g. from your DB; start at new Date().toISOString()
 
async function pollOnce() {
  let page = 1
  let totalPages = 1
  let maxUpdatedAt = cursor
 
  while (page <= totalPages) {
    const params = new URLSearchParams({
      updatedSince: cursor,
      limit: '100',
      page: String(page),
    })
    const response = await fetch(`${BASE}/orders?${params}`, {
      headers: HEADERS,
    })
    if (!response.ok) throw new Error(`Poll failed: ${response.status}`)
 
    const { items, totalPages: tp } = await response.json()
    totalPages = tp
 
    for (const order of items) {
      await upsertOrderLocally(order) // dedupe by order.id + updatedAt
      if (order.updatedAt > maxUpdatedAt) maxUpdatedAt = order.updatedAt
    }
    page += 1
  }
 
  cursor = maxUpdatedAt
  await saveCursor(cursor)
}
 
setInterval(() => pollOnce().catch(console.error), 20_000)

Why not poll by status?

Filtering by status=new alone misses later changes (e.g. a guest-cancelled order you already imported). The updatedSince cursor catches every change exactly because it keys on updatedAt, not on a single status.