All notes

Product · October 28, 2026

New in Pigeon: Marketing

So Pigeon now has a Marketing section. It is shipping now and will be live for every project very soon.

Pigeon started with one simple idea. Sending email from your app should be easy to set up and easy to understand. You write a template, you make one API call, and you can read every log line afterwards.

Many of you also asked for a way to talk to your users after they sign up. A welcome series, a nudge when someone stops logging in, a short note about a new feature. So Pigeon now has a Marketing section. It is shipping now and will be live for every project very soon.

Here is what it includes.

Audiences

Every project gets its own Audiences. An audience is a list of contacts, and each contact can carry custom attributes. A good example is `last_access`, the last time a user opened your app. You decide which attributes matter for your product.

If you already have users, you can bring them in with the bulk import API. Send up to 1,000 contacts in one request to `POST /api/audiences/:id/imports` and they show up in your audience, attributes included.

A signup webhook

For new users, your app can push contacts to Pigeon in real time. Point your signup flow at the webhook and every new user lands in your audience right away.

There are two endpoints. Both take the same body.

  • `POST /api/audiences/:id/events` adds the contact to the audience in the path.
  • `POST /api/events` uses `audience_id` from the body, or your project's default audience when you leave it out.

Authenticate with your project API key in the `Authorization: Bearer pg_...` header, the same key you already use for the rest of the API. A request body looks like this. Only `email` is required.

JSON
{
  "email": "maria@example.com",
  "external_id": "usr_1042",
  "occurred_at": "2026-10-27T16:00:00Z",
  "attributes": {
    "plan": "free",
    "last_access": "2026-10-27T16:00:00Z"
  }
}

And a full call:

shell
curl -X POST $PIGEON_URL/api/audiences/$AUDIENCE_ID/events \
  -H "Authorization: Bearer $PIGEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"maria@example.com","occurred_at":"2026-10-27T16:00:00Z","attributes":{"plan":"free","last_access":"2026-10-27T16:00:00Z"}}'

`attributes.last_access` is a typed timestamp. Every other attribute is yours to define. Pigeon answers `201` for a new contact and `200` when it updated an existing one, with the contact and two flags, `ignored` and `duplicate`:

JSON
{
  "id": "9a14d0c2-5e88-4b1f-8c33-0d6e2a91f804",
  "email": "maria@example.com",
  "external_id": "usr_1042",
  "marketing_subscribed": true,
  "unsubscribed": false,
  "consented": true,
  "attributes": {
    "plan": "free",
    "last_access": "2026-10-27T16:00:00Z"
  },
  "created_at": "2026-10-27T16:00:01Z",
  "updated_at": "2026-10-27T16:00:01Z",
  "ignored": false,
  "duplicate": false
}

Signing is optional. If you want an extra check, send `X-Pigeon-Timestamp` (unix seconds) and `X-Pigeon-Signature`, which is `sha256=` plus the HMAC SHA256 of `timestamp.body` made with the audience's webhook secret. Pigeon accepts a timestamp within 300 seconds of now.

To make retries safe, send an `X-Pigeon-Idempotency-Key` header. A repeated key returns the first result with `duplicate` set to true, so no second contact is created. When two events arrive for the same contact, the one with the newer `occurred_at` wins. An older event is ignored (`ignored` is true), so old data never overwrites fresh data.

A new contact is enrolled automatically into every active campaign for that audience, unless `marketing_subscribed` is false for that contact. You do not need to run anything by hand. Updates to an existing contact do not enroll it again.

A drag and drop campaign builder

Campaigns are built visually. You place blocks on a canvas and connect them:

  • Audience entry: where contacts start. Choose `audience` to enroll everyone already on the list when you activate the campaign, plus new signups. Choose `contact_added` to enroll only new signups from the webhook.
  • Condition: for example, `last_access` is older than 7 days.
  • Wait: pause for a number of hours.
  • Send template: send one of your existing Pigeon templates.

Pigeon checks your campaign before you turn it on. Every path has to end in a send, so you never build a branch that quietly does nothing.

Unsubscribes, handled for you

Every contact has a `marketing_subscribed` flag. It starts as true. When someone clicks the unsubscribe link in a campaign email, the flag flips to false. You can also set it from the webhook or the contacts API with `marketing_subscribed: false`, `unsubscribed: true` or `consented: false`. From that moment, that contact is not enrolled in campaigns and does not receive campaign emails. Add the `{{unsubscribe}}` link to every template you use in a send block. Your transactional emails, like password resets and receipts, keep working as before.

Separate sending limits

Marketing email has its own limits, apart from your transactional quota. By default each project can send 50 marketing emails per day and 500 per month (`marketing_daily_limit` and `marketing_monthly_limit`). You can change both in Settings. When a campaign reaches a limit, the remaining contacts wait and are picked up in a later run. A busy campaign will never use up the quota you need for login codes and receipts.

Try it and tell me what you think

The Marketing section is built in the same spirit as the rest of Pigeon. Few moving parts, clear logs, and no surprises.

When it lands in your dashboard, create an audience, connect the signup webhook, and build a small campaign. Even a simple "we miss you" email after 14 days of no activity is a good first test.

I build Pigeon on my own from Barcelona, so your feedback really shapes what comes next. If something feels confusing, or you need a block that is not there yet, just reply to any Pigeon email or write to me directly. I read every message.

Alex