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

# SendGrid

> Mail Send, scheduling, templates, API keys, Marketing, Event Webhook, and a local inbox.

Stateful SendGrid v3 emulation: Mail Send with SendGrid's validation and error envelope, dynamic templates rendered with Handlebars, API keys with scopes, Marketing contacts and lists with SGQL search, the Event Webhook with ECDSA signatures, suppressions, and an inbox. Nothing is ever delivered to a real mailbox. The tests drive it with the official [`@sendgrid/mail`](https://www.npmjs.com/package/@sendgrid/mail) and [`@sendgrid/client`](https://www.npmjs.com/package/@sendgrid/client) (8.1.6), and verify webhook signatures with [`@sendgrid/eventwebhook`](https://www.npmjs.com/package/@sendgrid/eventwebhook) (8.0.0).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/sendgrid` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `sendgrid` |
| HTTP operations | 65, browsable at `http://localhost:3999/docs/sendgrid` while it runs ([API reference](/api-reference.md)) |
| Vendor spec | [twilio/sendgrid-oai](https://github.com/twilio/sendgrid-oai) at `fb95a93` |

```bash CLI theme={null}
npx @deploybaseline/cli start --service sendgrid
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const sendgrid = await createEmulator({ service: "sendgrid" });
// sendgrid.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "sendgrid": {
    "api_keys": [
      {
        "name": "Local key",
        "key": "SG.baseline_local.local_secret_for_development_only"
      }
    ],
    "templates": [
      {
        "name": "Welcome",
        "generation": "dynamic",
        "versions": [
          {
            "subject": "Welcome, {{name}}",
            "html_content": "<p>Hello {{name}}, thanks for joining.</p>"
          }
        ]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service sendgrid
```

SendGrid starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`.

Point `@sendgrid/client` and `@sendgrid/mail` at the emulator with the client's `baseUrl`. `setApiKey` resets the client's base URL, so set the base URL after it:

```ts theme={null}
import { Client } from "@sendgrid/client";
import { MailService } from "@sendgrid/mail";

const client = new Client();
client.setApiKey("SG.baseline_local.local_secret_for_development_only");
client.setDefaultRequest("baseUrl", `${process.env.SENDGRID_EMULATOR_URL}/`);

const mail = new MailService();
mail.setClient(client);

await mail.send({
  to: "ada@example.com",
  from: "noreply@acme.test",
  templateId: "d-0123456789abcdef0123456789abcdef",
  dynamicTemplateData: { name: "Ada" },
});
```

## URL mapping

| Real SendGrid URL | Emulator URL |
| - | - |
| `https://api.sendgrid.com/v3/...` | `$SENDGRID_EMULATOR_URL/v3/...` |

## Auth

Every `/v3` request needs `Authorization: Bearer <key>`. Keys seeded under `api_keys` or created with `POST /v3/api_keys` carry their scopes, and a route answers 403 `access forbidden` when the key lacks the scope it needs: Mail Send needs `mail.send`, templates need `templates.*`, Marketing needs `marketing.read`, and webhook settings need `user.webhooks.event.settings.*`. Any other token the CLI accepts acts as a Full Access key. A missing header is 401 `authorization required`; an unknown key is 401.

## Errors

Errors use SendGrid's `{ errors: [{ message, field, help }] }` body with its messages, for example `The from object must be provided for every email send.` with field `from`. Unknown `/v3` paths answer 404.

## Mail Send

`POST /v3/mail/send` takes:

* `personalizations` (1 to 1000), each with `to`, `cc`, `bcc`, `subject`, `headers`, `substitutions`, `dynamic_template_data`, `custom_args`, and `send_at`
* `from`, `reply_to` or `reply_to_list`, `subject`, and `content` (`text/plain` first, then `text/html`)
* `attachments` in base64; `disposition: inline` needs a `content_id`
* `template_id`, `categories` (up to 10), `custom_args`, `headers` (reserved headers are refused), `send_at`, `batch_id`, `asm`, and `ip_pool_name`
* `mail_settings.sandbox_mode` and `mail_settings.footer`

Accepted mail answers 202 with an empty body and an `X-Message-Id` header. Sandbox mode validates the request and answers 200 without sending. A body over 30 MB answers 413. Each personalization becomes one message in the inbox.

A dynamic template (`d-...`) renders its active version's subject, HTML, and text with each personalization's `dynamic_template_data`. A legacy template fills `<%subject%>` and `<%body%>`.

The Handlebars subset matches what SendGrid supports:

* `{{name}}` (HTML escaped) and `{{{html}}}`
* `{{#if}}`, `{{else}}`, `{{else if}}`, `{{#unless}}`, and `{{#with}}`
* `{{#each}}` with `this`, `@index`, `@key`, `@first`, `@last`, `../`, and `@root`
* `{{#equals a b}}`, `{{#notEquals}}`, `{{#greaterThan}}`, `{{#lessThan}}`, `{{#and}}`, and `{{#or}}`
* `{{insert x "default=y"}}`, `{{formatDate d "YYYY-MM-DD"}}`, and `{{length list}}`
* comments and `~` whitespace control

## Scheduling

`send_at` (unix seconds, up to 72 hours ahead) holds mail until then, and it goes out on its own timer. `POST /v3/mail/batch` creates a `batch_id`. `POST /v3/user/scheduled_sends` with `{ batch_id, status: "pause" | "cancel" }` holds or discards the batch, and `PATCH` and `DELETE /v3/user/scheduled_sends/{batch_id}` change or lift it. Paused mail more than 72 hours past its send time is discarded.

## Templates

`/v3/templates` creates, lists, gets, renames, and deletes templates (`generation: legacy | dynamic`). Listing needs `page_size` (1 to 200), filters with `generations`, and pages with `page_token`. `POST /v3/templates/{id}` duplicates a template. `/v3/templates/{id}/versions` creates, gets, updates, and deletes versions, and `POST /v3/templates/{id}/versions/{version_id}/activate` makes one active. Versions with Handlebars errors carry `warnings`.

## API keys

`GET /v3/scopes` returns the caller's scopes. `/v3/api_keys` creates a key with `name` and optional `scopes` (Full Access when left out), lists, gets, renames with `PATCH`, replaces name and scopes with `PUT`, and deletes. A key cannot grant scopes it lacks, and billing scopes cannot be mixed with others.

## Marketing

### Contacts

* `PUT /v3/marketing/contacts` — Upsert contacts: takes `{ list_ids?, contacts: [{ email, first_name, ..., custom_fields }] }`, upserts by email, and returns `{ job_id }`; read the job at `GET /v3/marketing/contacts/imports/{job_id}`
* `GET /v3/marketing/contacts` — List contacts
* `GET /v3/marketing/contacts/{id}` — Get contact
* `DELETE /v3/marketing/contacts?ids=` or `?delete_all_contacts=true` — Delete contacts
* `GET /v3/marketing/contacts/count` — Count contacts
* `POST /v3/marketing/contacts/batch` — Get contacts by id: with `{ ids }`
* `POST /v3/marketing/contacts/search/emails` — Search contacts by email: with `{ emails }`
* `POST /v3/marketing/contacts/search` — Search contacts: with an SGQL `query`, such as `email LIKE 'ada%' AND CONTAINS(list_ids, '<list id>')`

### Lists

* `/v3/marketing/lists`: create, list, get (`contact_sample=true`), rename, and delete (`delete_contacts=true`)
* `DELETE /v3/marketing/lists/{id}/contacts?contact_ids=` — Remove contacts from a list
* `GET /v3/marketing/lists/{id}/contacts/count` — Count list contacts

## Suppressions

Bounces, spam reports, and unsubscribes from the simulate routes or the seed join their suppression list. Later mail to the address is `dropped` with reason `Bounced Address`, `Spam Reporting Address`, or `Unsubscribed Address`.

* `GET` and `DELETE /v3/suppression/bounces` (`{ delete_all: true }` or `{ emails }`), and `GET` and `DELETE /v3/suppression/bounces/{email}`
* `GET /v3/suppression/spam_reports` and `DELETE /v3/suppression/spam_reports/{email}` — Spam reports: list them, or delete one by email
* `GET /v3/suppression/unsubscribes` — List unsubscribes

## Event Webhook

`POST /v3/user/webhooks/event/settings` creates a webhook (up to five) with `url`, `enabled`, a boolean per event (`processed`, `delivered`, `open`, `click`, `bounce`, `dropped`, `deferred`, `spam_report`, `unsubscribe`, `group_unsubscribe`, `group_resubscribe`), `friendly_name`, and an optional OAuth client (`oauth_client_id`, `oauth_client_secret`, `oauth_token_url`). `GET /v3/user/webhooks/event/settings/all` lists them, and `GET`, `PATCH`, and `DELETE /v3/user/webhooks/event/settings/{id}` manage one. The older `GET` and `PATCH /v3/user/webhooks/event/settings` act on the first webhook.

`PATCH /v3/user/webhooks/event/settings/signed/{id}` with `{ enabled: true }` turns on signing and returns `{ id, public_key }`. Events go out as one JSON array per request (a send posts `processed` and `delivered` for every recipient together), signed with ECDSA P-256 over `timestamp + body` in `X-Twilio-Email-Event-Webhook-Signature` and `X-Twilio-Email-Event-Webhook-Timestamp`:

```ts theme={null}
import { EventWebhook, EventWebhookHeader } from "@sendgrid/eventwebhook";

const ew = new EventWebhook();
const ok = ew.verifySignature(
  ew.convertPublicKeyToECDSA(publicKey),
  rawBody,
  req.headers[EventWebhookHeader.SIGNATURE().toLowerCase()],
  req.headers[EventWebhookHeader.TIMESTAMP().toLowerCase()],
);
```

With an OAuth client, the emulator fetches a client credentials token first and sends it as `Authorization: Bearer <token>`. Failed posts are retried three times. `POST /v3/user/webhooks/event/test` with `{ id?, url }` sends a sample batch, signed with that webhook's key when it has one.

## Simulate

`POST /_sendgrid/simulate/{event}` with `{ email, message_id?, url?, reason?, useragent? }` makes a recipient of sent mail do what a real inbox does. Events: `delivered`, `open`, `click`, `bounce`, `blocked`, `deferred`, `dropped`, `spamreport`, `unsubscribe`, `group_unsubscribe`, and `group_resubscribe` (group events need mail sent with `asm.group_id`). It updates the recipient, posts the event to the webhooks, and returns it. Without `message_id`, the latest mail to the address is used.

```bash theme={null}
curl -X POST "$SENDGRID_EMULATOR_URL/_sendgrid/simulate/bounce" \
  -H "Content-Type: application/json" -d '{"email":"ada@example.com"}'
```

## Seed config

```json theme={null}
{
  "sendgrid": {
    "api_keys": [
      {
        "name": "Local key",
        "key": "SG.baseline_local.local_secret_for_development_only"
      },
      {
        "name": "Send only",
        "scopes": ["mail.send"]
      }
    ],
    "templates": [
      {
        "name": "Welcome",
        "id": "d-0123456789abcdef0123456789abcdef",
        "versions": [
          {
            "subject": "Welcome, {{name}}",
            "html_content": "<p>Hello {{name}}</p>",
            "plain_content": "Hello {{name}}"
          }
        ]
      }
    ],
    "lists": [
      {
        "name": "Newsletter"
      }
    ],
    "contacts": [
      {
        "email": "ada@example.com",
        "first_name": "Ada",
        "lists": ["Newsletter"]
      }
    ],
    "webhooks": [
      {
        "url": "http://localhost:3000/sendgrid/events",
        "events": ["processed", "delivered", "bounce"],
        "signed": true
      }
    ],
    "suppressions": {
      "bounces": ["bounced@example.com"]
    }
  }
}
```

* `id`: generated when left out

Keys default to Full Access, and a key's middle part becomes its id. Templates are dynamic unless `generation: legacy`; the last version with `active: true` (or the first) is active. Webhook `events` default to all; read a signed webhook's public key from `GET /v3/user/webhooks/event/settings/signed`. Seeding again does not duplicate entries.

| Key | Purpose |
| - | - |
| `api_keys` | API keys: `name`, `key`, and `scopes` (Full Access when left out) |
| `templates` | Templates: `name`, `id` (generated when left out), `generation`, and `versions` (`subject`, `html_content`, `plain_content`, `active`) |
| `lists` | Marketing lists by `name` |
| `contacts` | Marketing contacts: `email`, `first_name`, and the `lists` they belong to |
| `webhooks` | Event Webhooks: `url`, `events` (all by default), and `signed` |
| `suppressions` | Suppressed addresses, such as `bounces` |

## Inbox and inspector

`GET /inbox` lists sent mail, and `/inbox/{message_id}` previews it. `GET /_inspector` has tabs for messages, templates, contacts and lists, webhooks with recent deliveries, suppressions, and API keys.

## Limits

* No SMTP relay, Single Sends, Automations, segments, custom field definitions, subusers, IP pools, or stats.
* Open and click tracking rewrite nothing; use the simulate routes for engagement events.
* Webhook retries run three times over about 20 seconds, where SendGrid retries for 24 hours.

## Conformance

| | |
| - | - |
| Routes | 70 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | fail, 8 allowed ([twilio/sendgrid-oai](https://github.com/twilio/sendgrid-oai) at `fb95a93`) |
| SDKs tested | `@sendgrid/client@8.1.6`, `@sendgrid/eventwebhook@8.0.0`, `@sendgrid/mail@8.1.6` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.