DEV Community

Ingest invoices and receipts into an agent mailbox

Provision the AP mailbox

Create the grant first. The API call is POST /v3/connect/custom with provider: "nylas" and the email on your domain. The optional name sets the display name.

curl --request POST \
  --url 'https://api.us.nylas.com/v3/connect/custom' \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "provider": "nylas",
    "name": "AP Inbox",
    "settings": {
      "email": "invoices@yourcompany.com"
    }
  }'

The response includes the grant_id - that's the only identifier you carry forward. Nylas also auto-creates a default workspace and policy for the account.

The CLI collapses the connector setup, the grant creation, and the default workspace into one command:

nylas agent account create invoices@yourcompany.com --name "AP Inbox"

If the Nylas connector doesn't exist in your app yet, this creates it first, then the grant. Add --json to capture the grant_id for a script.

Want to clamp inbound attachments before anything reaches your parser? The default policy is attached to the auto-created workspace; update it to point at a custom policy. The API call is PATCH /v3/workspaces/{workspace_id}:

curl --request PATCH \
  --url "https://api.us.nylas.com/v3/workspaces/$WORKSPACE_ID" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "policy_id": "<policy-id>"
  }'

CLI:

nylas workspace update <workspace-id> --policy-id <policy-id>

There's no --workspace flag on account create - the workspace is created for you, and you attach a custom policy afterward. Tune limit_attachment_size_limit, limit_attachment_count_limit, and limit_attachment_allowed_types on the policy so a 200-page scanned PDF or a disallowed file type gets rejected at the edge. See Policies, Rules, and Lists for the full shape.

Subscribe to inbound mail

Inbound mail fires the standard message.created webhook - nothing Agent-Account-specific to learn. Subscribe to it.

curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks' \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "trigger_types": ["message.created"],
    "webhook_url": "https://ap.yourcompany.com/webhooks/nylas",
    "description": "AP invoice ingestion"
  }'

CLI:

nylas webhook create \
  --url https://ap.yourcompany.com/webhooks/nylas \
  --triggers message.created \
  --description "AP invoice ingestion"

This post is scoped to inbound mail: you care about message.created for invoices that vendors send to the account. If your pipeline also auto-replies (a "we received your invoice" confirmation), guard your handler so it only acts on mail addressed to the account - filter on the sender at the top so you never try to parse your own outbound confirmation as an invoice.

Here's the skeleton handler. Ack fast, do the work off the request path:

// Node.js / Express
app.post("/webhooks/nylas", async (req, res) => {
  // Verify X-Nylas-Signature first -- see /docs/v3/notifications/.
  res.status(200).end();

  const event = req.body;
  if (event.type !== "message.created") return;

  const msg = event.data.object;
  if (msg.grant_id !== AP_GRANT_ID) return;

  // Only act on inbound mail -- skip anything from the account's own address.
  if (msg.from?.[0]?.email === "invoices@yourcompany.com") return;

  await ingestInvoice(msg);
});

Read the message and find its attachments

The webhook payload is a summary - subject, from, snippet, thread_id, and a list of attachment stubs. It deliberately doesn't carry the full body or the attachment bytes. (And if the body tops ~1 MB, the trigger becomes message.created.truncated and the body is dropped entirely.) So step one is to fetch the full message.

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$AP_GRANT_ID/messages/$MESSAGE_ID" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY"

The message object includes an attachments array. Each entry carries everything you need to decide what to download:

{
  "id": "msg_abc123",
  "subject": "Invoice INV-204 - Acme Supplies",
  "from": [{
    "name": "Acme Supplies",
    "email": "billing@acme.example"
  }],
  "attachments": [
    {
      "id": "att_9xkz",
      "filename": "INV-204.pdf",
      "content_type": "application/pdf",
      "size": 48213,
      "is_inline": false
    }
  ]
}

content_type and filename are your filter. For AP you typically want application/pdf and the spreadsheet types, skipping is_inline: true entries - those are logos and signature images embedded in the HTML, not documents.

The CLI gives you the same picture two ways. To read the full message:

nylas email read <message-id> [grant-id]

To list just the attachments on that message:

nylas email attachments list <message-id> [grant-id]

That second command is the one I lean on when I'm eyeballing what a vendor actually sent - it prints the attachment IDs, file names, and types in a table, which is exactly the input the download step needs. Pass --json and you've got machine-readable stubs to iterate over.

If you'd rather not run a webhook at all, polling works too. Listing messages on a cadence is a perfectly reasonable choice for a batch AP run that processes the day's invoices every evening:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$AP_GRANT_ID/messages?in=INBOX&limit=50" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY"

CLI:

nylas email list --folder INBOX --limit 50 --json

Webhooks win for near-real-time ("the invoice hit the inbox, kick off approval now"); polling wins for tidy nightly batches. Both are first-class.

Download the attachment bytes

This is the heart of document ingestion, and the one endpoint with a non-obvious shape. The download path requires the message_id as a query parameter - the attachment ID alone isn't enough to locate the bytes:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$AP_GRANT_ID/attachments/$ATTACHMENT_ID/download?message_id=$MESSAGE_ID" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY" \
  --output INV-204.pdf

The response streams the raw file bytes - no base64 wrapper, no JSON envelope. Pipe it straight to disk, an object store, or an in-memory buffer for your parser.

The CLI mirrors the same two-identifier requirement. Note the argument order - attachment ID first, then message ID:

nylas email attachments download <attachment-id> <message-id> [grant-id] -o INV-204.pdf

By default it writes to the attachment's original file name; -o overrides the output path. This is genuinely the same call as the curl above - the CLI is just URL-encoding the IDs and handling the stream for you, which matters because attachment IDs sometimes contain characters (#, /) that break an un-encoded URL.

There's also a metadata-only endpoint if you want the size and type before committing to a download - handy when a policy didn't pre-filter and you want to bail on anything over, say, 10 MB. Like the download call, it requires the message_id query parameter:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$AP_GRANT_ID/attachments/$ATTACHMENT_ID?message_id=$MESSAGE_ID" \
  --header 'Authorization: Bearer '"$NYLAS_API_KEY"

The CLI takes the same two positionals - attachment ID first, then message ID:

nylas email attachments show <attachment-id> <message-id> [grant-id]

Extract amount, date, and vendor - honestly

Here's where I'll be straight with you: Nylas hands you the document, not the parsed fields. There is no "extract invoice total" endpoint, and you shouldn't expect one. The amount/date/vendor extraction is your application code, and it generally looks like one of:

  • An LLM with the PDF or its extracted text. Pass the downloaded bytes (or text you've pulled with a PDF library) to a model and ask for structured JSON: { "vendor", "invoice_number", "amount", "currency", "due_date" }. This handles the messy long tail of vendor layouts that rules never will.
  • OCR for scanned/image PDFs. Plenty of invoices are photographed receipts. Run them through an OCR service first, then your LLM or regex over the text.
  • Cheap heuristics for the easy stuff. Some vendors put the total right in the email body. The full message you already fetched includes body - a regex for a currency pattern can catch the obvious cases before you spend a model call.

A reasonable pipeline composes all three: try the body regex, fall back to text extraction plus an LLM, fall back to OCR for image-only PDFs. Whatever you build, the Nylas side ends at the downloaded bytes. Frame your expectations there and you won't be surprised.

async function ingestInvoice(msg) {
  const message = await getMessage(AP_GRANT_ID, msg.id);
  const documents = message.attachments.filter(
    (a) => !a.is_inline && a.content_type?.startsWith("application/pdf")
  );

  for (const att of documents) {
    const bytes = await downloadAttachment(AP_GRANT_ID, att.id, msg.id);

    // Your code: LLM / OCR / regex over `bytes` and `message.body`.
    const parsed = await extractInvoiceFields(bytes, message.body);

    // Store in YOUR database -- metadata isn't supported on the message.
    await db.invoices.insert({
      sourceMessageId: msg.id,
      sender: message.from?.[0]?.email,
      filename: att.filename,
      ...parsed,
      ingestedAt: new Date(),
    });
  }
}

Where the parsed records live

This is the design decision that trips people up coming from email-tagging workflows: you can't store the parsed invoice on the message. Agent Accounts don't support custom metadata yet, so there's no field on the Nylas object to stash invoice_id or approved: true. That's not a limitation for AP - it's the right boundary. The durable record of an invoice belongs in your accounting database, keyed by your invoice number.

Comments

No comments yet. Start the discussion.