---
title: "Pull consumers"
description: "Receive messages with a long poll, acknowledge them, and extend or release them with their receipt handles."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.ezghcloud.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pull consumers

A pull consumer runs in your own code. It receives messages from `https://queues-live.ezghcloud.com`
with an API key, handles them, and acknowledges each one it finished with its receipt handle. It
needs `queues.messages.receive` on the queue, and outbound HTTPS only.

## Receive messages

```sh
curl -sS -X POST "https://queues-live.ezghcloud.com/v1/organizations/$ORG_ID/projects/$PROJECT_ID/queues/$QUEUE_ID/messages/receive" \
  -H "Authorization: Bearer $EZGH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "maxMessages": 10, "waitSeconds": 20, "visibilityTimeoutSeconds": 60 }'
```

[ReceiveMessages](/queues-api/ReceiveMessages/) takes, all optional:

| Field | Default | Range |
| --- | --- | --- |
| `maxMessages` | 1 | 1 to 10 |
| `waitSeconds` | The queue's `receiveWaitSeconds` | 0 to 20 |
| `visibilityTimeoutSeconds` | The queue's `visibilityTimeoutSeconds` | 0 to 43,200 |

It answers `200` with up to `maxMessages` messages as soon as there are any, or with
`{"messages": []}` when the wait ends. Give your HTTP client a timeout longer than `waitSeconds`.
Each message has:

| Field | Meaning |
| --- | --- |
| `messageId` | The message's ID, the same on every delivery |
| `receiptHandle` | Acknowledges or changes the visibility of this delivery |
| `body`, `attributes` | As sent. A dead-lettered message adds `ezgh.` attributes ([Dead-letter queues](/queues/dead-letter-queues)) |
| `sentAt` | When it was sent |
| `firstReceivedAt` | When it was first received |
| `receiveCount` | How many times it has been received, this time included |
| `groupId`, `seq` | FIFO queues only: its message group, and its sequence number in the group (a string of digits) |

A receive can answer fewer messages than are visible. With many messages in flight (120,000 on a
standard queue, 20,000 on a FIFO queue) it answers no more until some are acknowledged or become
visible again.

From the CLI, [`ezgh queues receive`](/cli/reference/queues-receive) takes `--max`, `--wait`
and `--visibility-timeout`, and prints each message with its receipt handle; `--ack` acknowledges
them once printed. [`ezgh queues ack`](/cli/reference/queues-ack) and
[`ezgh queues change-visibility`](/cli/reference/queues-change-visibility) take receipt handles.

## Acknowledge messages

When you've handled a message, acknowledge it, which deletes it:

```sh
curl -sS -X POST https://queues-live.ezghcloud.com/v1/receipts/ack \
  -H "Content-Type: application/json" \
  -d '{ "receiptHandles": ["AQAAAH8…", "AQAAAJ2…"] }'
```

```json
{ "results": [ { "ok": true }, { "error": "receipt_handle_stale" } ] }
```

- **No credential.** `POST /v1/receipts/ack` reads no API key or session: the receipt handle is
  what allows it. Browser code on any origin can call it, without credentials.
- **1 to 10 handles** a request, from any of your queues.
- **One result per handle, in order:** `{"ok": true}`, or `{"error": …}`:

| Error | Meaning | What to do |
| --- | --- | --- |
| `receipt_handle_stale` | The handle's delivery is over: the message was received again, already acknowledged, moved to a dead-letter queue, purged, expired, or its queue was deleted. Nothing changed | Don't retry. If it was received again, that delivery will handle it |
| `receipt_handle_invalid` | Not a receipt handle, or altered | Check that you pass the handle exactly as received |

The whole request answers `400` with 0 or more than 10 handles. A client address that sends more
than 100 invalid handles a minute gets `429` with `Retry-After`.

A handle still works after the visibility timeout ends, until another receive takes the message.
Acknowledging the same delivery twice answers `receipt_handle_stale` the second time.

## Extend or release a message

Change how long messages stay in flight, counted from now:

```sh
curl -sS -X POST https://queues-live.ezghcloud.com/v1/receipts/visibility \
  -H "Content-Type: application/json" \
  -d '{ "entries": [ { "receiptHandle": "AQAAAH8…", "visibilityTimeoutSeconds": 120 } ] }'
```

- **Extend** a message your consumer needs longer for, before its visibility timeout ends. A
  message stays in flight for at most 12 hours from its receive: a longer extension ends there.
- **Release** a message your consumer won't handle with `"visibilityTimeoutSeconds": 0`. It's
  visible at once to the next receive.
- `visibilityTimeoutSeconds` is 0 to 43,200. Requests take 1 to 10 entries and answer the same
  `results` as acknowledging, with the same errors and limits.

## A consumer loop

This TypeScript loop runs with Bun, or Node.js 24 or later. Messages it fails to handle stay in
flight until their visibility timeout ends, then are received again: the timeout is the delay
between attempts. Give the queue a [dead-letter queue](/queues/dead-letter-queues) so a message
that always fails stops being retried.

```ts title="consumer.ts"
const live = "https://queues-live.ezghcloud.com"
const { EZGH_API_KEY, ORG_ID, PROJECT_ID, QUEUE_ID } = process.env
const receiveUrl = `${live}/v1/organizations/${ORG_ID}/projects/${PROJECT_ID}/queues/${QUEUE_ID}/messages/receive`
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

async function handle(message: { messageId: string; body: string; attributes: Record<string, string> }) {
  console.log(message.messageId, message.body)
}

while (true) {
  const res = await fetch(receiveUrl, {
    method: "POST",
    headers: { Authorization: `Bearer ${EZGH_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ maxMessages: 10, waitSeconds: 20 }),
    signal: AbortSignal.timeout(30_000),
  })
  if (res.status === 429 || res.status >= 500) {
    await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000)
    continue
  }
  if (!res.ok) throw new Error(`receive: ${res.status} ${await res.text()}`)

  const { messages } = await res.json()
  const handled: string[] = []
  for (const message of messages) {
    try {
      await handle(message)
      handled.push(message.receiptHandle)
    } catch (err) {
      console.error(`message ${message.messageId} failed (receive ${message.receiveCount})`, err)
    }
  }
  if (handled.length > 0) {
    await fetch(`${live}/v1/receipts/ack`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ receiptHandles: handled }),
    })
  }
}
```

Run several consumers to handle more: each receive claims different messages. On a FIFO queue,
each message group goes to one consumer at a time ([FIFO queues](/queues/fifo#receiving)).

## Errors

| Status | When |
| --- | --- |
| `400` | A field is out of range |
| `401`, `403` | The credential is missing or invalid, or lacks `queues.messages.receive` on the queue |
| `404` | No such queue in the project, or it was deleted while the receive waited |
| `429` | Over the queue's or organization's [rate limit](/queues/limits#rate-limits). Wait for `Retry-After` |
| `503` | Try again after `Retry-After` |

## Pull consumers and subscriptions

A queue with a [push subscription](/queues/push) can still be received from. The subscription and
pull consumers then compete for the queue's messages: each message goes to whichever claims it
first.

Source: https://docs.ezghcloud.com/queues/consumers/index.mdx
