# SendGrid Integration Guide
**Cold Bore Ballistics LLC — Confidential**
Last updated: 2026-06-06

> This guide covers the repeatable pattern for wiring a new CBB service into SendGrid for transactional email delivery. It is written for an engineer who has never used SendGrid before. For the current account state (API keys, domain IDs, DNS records), see `SendGrid_Architecture_Reference.md`. For day-to-day operations, see `SendGrid_Operating_Runbook.md`.

---

## When to Use This Guide

Use this guide when you need to add a new service (a script, a Twilio Function, a Container App, etc.) that must send transactional email on behalf of CBB or ScopeDOPE.

Do **not** use SendGrid for:
- User-to-user email (use M365 directly)
- Marketing / bulk campaigns (use a dedicated campaign tool — SendGrid free tier has a 100/day ceiling and no campaign management)

---

## Overview

The integration pattern is always the same three steps:

1. **Create a scoped API key** in the SendGrid Console — minimum permissions required for the service
2. **Store the key in Azure Key Vault** — never in code, config files, or environment variables that are committed to git
3. **Inject the key into the service at runtime** — how this works depends on the service type (see Step 3 variants below)

---

## Step 1 — Create a Scoped API Key

Every service gets its own API key. Shared keys are forbidden — if a key is compromised, only the affected service is rotated.

1. Go to `https://app.sendgrid.com` and sign in with `admin@coldboreballisticsllc.com`
2. Navigate to: **Settings → API Keys → Create API Key**
3. Name the key using the pattern: `<service-name>` (e.g., `twilio-ivr`, `feedback-service`, `cbb-alert-script`)
4. Set permissions:
   - If the service only sends email: select **Restricted Access** → expand **Mail Send** → set to **Full Access**. Leave all other categories at **No Access**.
   - If the service needs to query delivery stats or manage suppressions: add only the specific categories needed. When in doubt, start with Mail Send only and expand later.
   - Only the `claude-code-cli` operator key uses **Full Access** — service keys never get full access.
5. Click **Create & View**
6. **Copy the key immediately** — it is shown only once. If you navigate away without copying, delete the key and create a new one.

---

## Step 2 — Store the Key in Azure Key Vault

All SendGrid API keys are stored in `kv-cbb-shared-prod`. The naming convention is:

```
sendgrid-<service-name>-secret
```

Examples: `sendgrid-twilio-ivr-secret`, `sendgrid-feedback-service-secret`

Store the key:
```bash
az keyvault secret set \
  --vault-name kv-cbb-shared-prod \
  --name sendgrid-<service-name>-secret \
  --value "<key-value>"
```

Verify it stored correctly:
```bash
az keyvault secret show \
  --vault-name kv-cbb-shared-prod \
  --name sendgrid-<service-name>-secret \
  --query value -o tsv
```

Update `SendGrid_Architecture_Reference.md` with the new key name, its permission scope, and which service uses it.

---

## Step 3 — Inject the Key into the Service

How the key reaches the service depends on where the service runs.

### Variant A — Twilio Function

Twilio Functions read secrets from environment variables set on the Function environment. Variables are stored in Twilio (not in the function code) and are only visible to that environment.

```bash
# Retrieve the key from vault
SG_KEY=$(az keyvault secret show \
  --vault-name kv-cbb-shared-prod \
  --name sendgrid-<service-name>-secret \
  --query value -o tsv)

# Set (or create) the variable on the Function environment
# Replace SERVICE_SID, ENV_SID, and auth credentials with values from Twilio_Architecture_Reference.md
TWILIO_AUTH=$(az keyvault secret show \
  --vault-name kv-scope-dope-prod \
  --name twilio-scopedope-auth-token \
  --query value -o tsv)

curl -s -X POST \
  "https://serverless.twilio.com/v1/Services/<SERVICE_SID>/Environments/<ENV_SID>/Variables" \
  -u "<SUBACCOUNT_SID>:$TWILIO_AUTH" \
  --data-urlencode "Key=SENDGRID_API_KEY" \
  --data-urlencode "Value=$SG_KEY"
```

In the Function code, access it as `context.SENDGRID_API_KEY`.

After setting the variable, redeploy the Function (create a new build and deployment) for the change to take effect.

### Variant B — Azure Container App

Container Apps with a system-assigned managed identity can read directly from Key Vault at runtime — no environment variable needed. The app reads the secret via the Azure SDK:

**Kotlin (KMP/Android — not applicable for Container Apps)**

**Python (Flask — feedback-service pattern):**
```python
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

credential = DefaultAzureCredential()
client = SecretClient(vault_url="https://kv-cbb-shared-prod.vault.azure.net", credential=credential)
sg_key = client.get_secret("sendgrid-feedback-service-secret").value
```

`DefaultAzureCredential` uses the Container App's managed identity automatically — no credentials in code.

The Container App's managed identity must have **Key Vault Secrets User** role on `kv-cbb-shared-prod`. Grant it:
```bash
PRINCIPAL_ID=$(az containerapp show \
  --name <app-name> \
  --resource-group rg-cbb-prod \
  --query identity.principalId -o tsv)

az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee "$PRINCIPAL_ID" \
  --scope "/subscriptions/<SUB_ID>/resourceGroups/rg-cbb-prod/providers/Microsoft.KeyVault/vaults/kv-cbb-shared-prod"
```

### Variant C — Local script / cbb-utils

Scripts pull the key from vault at runtime and never store it locally:

```bash
SG_KEY=$(az keyvault secret show \
  --vault-name kv-cbb-shared-prod \
  --name sendgrid-<service-name>-secret \
  --query value -o tsv)

curl -s -X POST "https://api.sendgrid.com/v3/mail/send" \
  -H "Authorization: Bearer $SG_KEY" \
  -H "Content-Type: application/json" \
  -d "{ ... }"
```

Never assign the key to a shell variable that persists (e.g., don't `export` it), and never log it.

---

## Step 4 — Send an Email

The SendGrid Mail Send API takes a JSON payload. The CBB standard format includes custom headers for programmatic filtering:

```bash
curl -s -X POST "https://api.sendgrid.com/v3/mail/send" \
  -H "Authorization: Bearer $SG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "personalizations": [{"to": [{"email": "destination@coldboreballisticsllc.com"}]}],
    "from": {"email": "noreply@scope-dope.com", "name": "ScopeDOPE"},
    "subject": "[Tag] Subject line",
    "content": [{"type": "text/plain", "value": "Message body."}],
    "headers": {
      "X-CBB-Source": "<service-name>",
      "X-CBB-Category": "<category-slug>",
      "X-CBB-Option": "<option-or-context>"
    }
  }'
```

**Sender address rules (from CLAUDE.md):**
- ScopeDOPE system email: `noreply@scope-dope.com` (name: `ScopeDOPE <service>`)
- CBB Corporate system email: `noreply@coldboreballisticsllc.com` (name: `Cold Bore Ballistics <service>`)

Both domains are authenticated in SendGrid — DKIM and SPF pass on both. Do not use any other sender domain.

A `202` response with no body means the message was accepted. Delivery failures appear in the SendGrid Activity Feed (`app.sendgrid.com` → **Activity**).

---

## Step 5 — Update Documentation

After completing the integration:

1. **`SendGrid_Architecture_Reference.md`** — add a row to the API Keys table with the new key name, its scope, and the service it belongs to
2. **The service's own README or architecture doc** — note that it uses SendGrid and which vault secret name it reads
3. **`SendGrid_Rebuild_Guide.md`** — add the new key to Step 5 (Create API Keys) so it is recreated during a rebuild

---

## Rotation

When a SendGrid API key needs to be rotated:

1. Create a new key in the SendGrid Console with identical permissions (do not delete the old one yet)
2. Store the new key in Key Vault under the same secret name (az keyvault secret set overwrites the current version)
3. Redeploy or restart the service so it picks up the new value
4. Verify the service is functioning correctly
5. Delete the old key from the SendGrid Console

For Twilio Functions specifically: updating the Key Vault secret is not enough — the Function environment variable must also be updated (Step 3 Variant A above), and a new build/deployment created.

See `SendGrid_Operating_Runbook.md` → Procedure: Rotate SendGrid API Key for IVR for the fully scripted version of this process for the `twilio-ivr` key.

---

## Document Control

| Field | Value |
|---|---|
| **Document owner** | `jschwefel@coldboreballisticsllc.com` |
| **Classification** | Confidential (03_-_Confidential/) |
| **Location** | `OneDrive/CBB_Corporate/03_-_Confidential/Infrastructure/SendGrid/SendGrid_Integration_Guide.md` |
| **Cross-references** | `SendGrid_Architecture_Reference.md`; `SendGrid_Operating_Runbook.md`; `Twilio_Integration_Guide.md` |
| **Created** | 2026-06-06 |
| **Last updated** | 2026-06-06 |
| **Next full review due** | 2027-06-06 |

---

## Revision Control Block

| Rev | Date | Changed By | Reviewed By | Approved By | Summary |
|-----|------|------------|-------------|-------------|---------|
| 0.9 | 2026-06-06 | jschwefel | — | — | Initial release |
