# Read customer replies

How replies of customers about your invoices reach you, through the invoice.reply.received webhook and the invoice-replies endpoints, what is masked in each, and how to download their attachments.

A **reply** is a message in which a customer answers about one of your invoices. BeeL. stores
replies from two sources and gives them one shape:

- the ones it receives itself through a [connection](/guides/connections), for example the email
  replies captured by `beel_email` (see [Capture replies by email](/guides/reply-capture-by-email));
- the ones you [register](/guides/registering-replies) from your own channels.

Each reply has a `channel` (the medium the customer wrote in), a `connection_id` (`null` when you
registered it) and `authenticated_by`, which says what links the message to its invoice:
`REPLY_TOKEN` for an answer to an invoice email, `API_KEY` for one you registered. It never says who
wrote it.

## Get them as they arrive

Subscribe a [webhook](/webhooks) to `invoice.reply.received`. It is delivered when a reply is stored,
also when you register it yourself. Subscribing needs the `invoice-replies:read` scope, and the event
only reaches the endpoints of the account that owns the company, never those of an account that
manages it.

```json
{
  "id": "9b2f4c1d-7e3a-4b8c-9d0e-1f2a3b4c5d6e",
  "type": "invoice.reply.received",
  "created_at": "2026-11-03T09:12:45Z",
  "api_version": "2025-01",
  "livemode": true,
  "company_id": "9f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "nif": "B12345674",
  "data": {
    "reply_id": "3f0c9a1e-6b2d-4c8f-a1e5-7d9b0c2e4f61",
    "channel": "EMAIL",
    "connection_id": "8c3e1f7a-2b4d-4e6f-9a1c-5d7b3e9f2a41",
    "invoice_id": "b71d5e2a-9c3f-4a7b-8e10-2f6d4c8a9b13",
    "invoice_number": "F2026-0142",
    "authenticated_by": "REPLY_TOKEN",
    "customer_id": "7a2c9e14-3b5d-4f6a-8c0e-1d2b3a4c5e6f",
    "received_at": "2026-11-03T09:12:44Z",
    "sender": { "address": "[EMAIL]" },
    "subject": "RE: Factura F2026-0142",
    "excerpt": "Hola, el CIF que aparece no es el nuestro, es [NIF]. ¿Podéis corregirla?",
    "attachments": []
  }
}
```

The [event page](/webhook-events/onInvoiceReplyReceived) has the full schema. Events do not
necessarily arrive in the order they happened. Deduplicate and verify the signature as for any
other [webhook](/webhooks/signatures).

## Read them on demand

- [List the replies](/invoice-replies/listCompanyInvoiceReplies) of a company, most recent first,
  filtering by `invoice_id`, `connection_id`, `customer_id`, `channel` (a comma-separated list),
  `from`, `to` and `sort_order`. A reply you registered has no connection, so a `connection_id`
  filter never returns it.
- [Get one reply](/invoice-replies/getCompanyInvoiceReply) with its full text.

Both need `invoice-replies:read`. See [Permissions](/guides/reply-permissions).

## What is masked

Replies carry personal data of your customers, and often of third parties. The webhook and the list
mask what they show, and only the detail of one reply gives you the original text.

| Field | Webhook and list | Detail of one reply |
|---|---|---|
| `excerpt` | First paragraph of the text, up to 280 characters, without the quoted original email. Tax IDs, IBANs, email addresses and phone numbers become `[NIF]`, `[IBAN]`, `[EMAIL]` and `[PHONE]`. | The same `excerpt`. |
| `text` | Not included. | The full text, unmasked, up to 20,000 characters; `is_text_truncated` tells you when it was cut. |
| `subject` | Masked in the same way. | The same masked `subject`. |
| `sender.address` | `[EMAIL]`, `[PHONE]` or `[ID]`, by `channel`. | The same masked address. |
| `sender.name` | Never included. | The display name, when the channel carries one. |
| `external_id` | Not included. | The identifier of the message on its channel. |
| `attachments` | `id`, `url`, `content_type`, `size_bytes` and `file_expires_at`. | Also the `filename`. |

BeeL. does not store the files of the attachments: see [Attachments](#attachments).

`sender` is the address the message says it comes from, and it is not verified. Do not use it alone
to authorize a change.

## Attachments

Each attachment of a reply carries an `id`, a `url`, its `content_type`, its `size_bytes` and
`file_expires_at`, the same in the `invoice.reply.received` webhook, in the list and in the detail of
the reply. The detail also gives the `filename`.

```json
"attachments": [
  {
    "id": "5b0e2c7a-91d4-4f3e-8a6b-2c1d9e0f7a34",
    "url": "/v1/companies/9f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f/invoice-replies/3f0c9a1e-6b2d-4c8f-a1e5-7d9b0c2e4f61/attachments/5b0e2c7a-91d4-4f3e-8a6b-2c1d9e0f7a34",
    "content_type": "application/pdf",
    "size_bytes": 48213,
    "file_expires_at": "2026-11-18T10:15:00Z"
  }
]
```

BeeL. does not store the file. It can be downloaded for a few days after the reply arrives, until
around `file_expires_at`, through a temporary link that BeeL. requests when you ask for it.

### Download a file

1. Call the `url` of the attachment with your API key. It is relative to the API base URL,
   `https://app.beel.es/api`, and points to
   [`GET /v1/companies/{company_id}/invoice-replies/{reply_id}/attachments/{attachment_id}`](/invoice-replies/getCompanyInvoiceReplyAttachment),
   which needs `invoice-replies:read`.
2. The response carries `download_url`, the temporary link to the file, and
   `download_url_expires_at`, about an hour later.
3. Download `download_url` before `download_url_expires_at`, without your API key: the link goes to the
   file, not to the BeeL. API, so do not send it the `Authorization` header.

```bash
# 1. Ask for a download link
curl "https://app.beel.es/api$ATTACHMENT_URL" \
  -H "Authorization: Bearer $BEEL_API_KEY"

# 2. Download the file with the download_url of the response, without the API key
curl -o justificante-transferencia.pdf "$DOWNLOAD_URL"
```

Do not keep `download_url`: when you need the file again, call `url` again for a new link, while the
file is still available.

### When there is no file

| Value | Meaning |
|---|---|
| `file_expires_at: null` | The attachment never had a file: it was registered with `POST .../invoice-replies`, which stores only its description. Its `download_url` is always `null`. |
| `download_url: null` | There is no file to download, and `download_url_expires_at` is `null` too. Either the attachment never had one, or its file is no longer available. That is the case after `file_expires_at`, and it can happen earlier, because `file_expires_at` is an estimate. Calling again does not bring the file back. |
| `attachments: null` | The reply is past its [retention](/guides/reply-data-protection#retention) of 12 months after `received_at` and its attachments were erased. Asking for one of them answers `404` [`INVOICE_REPLY_ATTACHMENT_NOT_FOUND`](/errors/INVOICE_REPLY_ATTACHMENT_NOT_FOUND). |

### Errors

| Status | Code | What to do |
|---|---|---|
| `404` | [`INVOICE_REPLY_NOT_FOUND`](/errors/INVOICE_REPLY_NOT_FOUND) | The reply does not exist, belongs to another company or environment, or was deleted. |
| `404` | [`INVOICE_REPLY_ATTACHMENT_NOT_FOUND`](/errors/INVOICE_REPLY_ATTACHMENT_NOT_FOUND) | The reply has no attachment with that id, or its attachments were erased. Use the `url` as it comes in the reply. |
| `429` | [`TOO_MANY_REQUESTS`](/errors/TOO_MANY_REQUESTS) | Too many download links were requested in a short time. Wait the seconds in the `Retry-After` header and call again. |
| `429` | [`RATE_LIMIT_EXCEEDED`](/errors/RATE_LIMIT_EXCEEDED) | Your key went over its [rate limit](/guides/rate-limits). Wait the seconds in `Retry-After` and call again. |
| `502` | [`EXTERNAL_SERVICE_ERROR`](/errors/EXTERNAL_SERVICE_ERROR) | The link could not be requested. It is transient: call again later, before `file_expires_at`. |

A missing scope or access answers `403`, as in [Permissions](/guides/reply-permissions).

### Keep the files you need

BeeL. never keeps a copy of a file. If you need to keep an attachment, download it when
`invoice.reply.received` arrives: for each attachment whose `file_expires_at` is not `null`, call its
`url` and save the file in your own storage. Once the file is gone, neither BeeL. nor the API can
recover it.

## Fields that can be `null`

`invoice_id`, `invoice_number` and `customer_id` can be `null` (a customer is `null` when the invoice
has a one-off recipient), and `subject` is `null` on every channel but `EMAIL`. After the
[retention period](/guides/reply-data-protection#retention) the content fields are `null` too. Handle
`null` in each of them.

## Related

<Related>

- [Register replies from other channels](/guides/registering-replies): WhatsApp, Slack and the rest
- [Delete and retention](/guides/reply-data-protection): erasure requests and the 12 months
- [Webhooks](/webhooks): endpoints, signatures and retries

</Related>

---

Full OpenAPI spec: https://docs.beel.es/api/openapi