Skip to content

Create a webhook

Register an HTTPS endpoint, and the events you want posted to it.

http
POST /v1/webhooks

Requires a Full access API key — a webhook can be pointed anywhere, so a sending key embedded in an app cannot create one.

Body parameters

ParameterTypeRequiredDescription
endpointstringYesThe HTTPS URL we POST events to. Must resolve to a publicly routable address — see below. Max 2048 characters.
eventsstring[]YesThe event types to send. At least one, each from the event catalog.
namestring | nullNoA label for your own use. Max 120 characters.
statusstringNoenabled or disabled. Defaults to enabled.

You do not supply a signing secret. We generate one — see The secret is ours to generate.

Example

bash
curl -X POST 'https://api.sendgrail.com/v1/webhooks' \
  -H "Authorization: Bearer $SENDGRAIL_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Production events",
    "endpoint": "https://hooks.example.com/sendgrail",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  }'
js
const res = await fetch('https://api.sendgrail.com/v1/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SENDGRAIL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Production events',
    endpoint: 'https://hooks.example.com/sendgrail',
    events: ['email.delivered', 'email.bounced', 'email.complained'],
  }),
});

const webhook = await res.json();

Response

201 Created.

json
{
  "object": "webhook",
  "id": "5f1c3a9e-0d42-4b7c-9a63-2e8b41d7c015",
  "name": "Production events",
  "endpoint": "https://hooks.example.com/sendgrail",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "status": "enabled",
  "created_at": "2026-07-14T19:44:32+00:00",
  "signing_secret": "whsec_3kQpV8ZrN2xLbT7yaJ6uWfE1sHdC4gMoR9vKtXqBnZyPmA0j"
}

signing_secret is what you use to verify that a delivery really came from us. Store it with the same care as an API key. The Webhooks guide has working verification code — this page won't repeat it.

The secret comes back on a create, a retrieve and a rotate, but not in a list. A list is the call you make casually, and there is no reason for it to hand back every secret you own.

The secret is ours to generate

There is no way to supply your own signing secret, on create or on update. If there were, someone would eventually choose a weak one, and a signature is only worth as much as the secret behind it. Ours is 48 random characters behind a whsec_ prefix.

The signing secret is redacted from the request log

Every API call is recorded in your request log, response bodies included. A create response carries a signing_secret — so without care, creating a webhook would write its own signing secret into the log in the clear, where any Full-access key could read it back out forever, long after the response itself was gone.

So signing_secret is stored as [redacted] in the log. The value in the HTTP response you're reading right now is real; the copy in the log is not. If you lose it, the log will not give it back — retrieve the webhook instead.

The endpoint must be public

endpoint must be an HTTPS URL whose host resolves only to publicly routable addresses. Loopback (127.0.0.1, localhost), private ranges (10.0.0.0/8, 192.168.0.0/16, …), link-local addresses and the cloud metadata endpoint 169.254.169.254 are all rejected. A host that doesn't resolve at all is rejected too — we can't confirm it is public.

The reason is that we make this request, from our network. An internal URL would turn a webhook into a way to have SendGrail's servers reach places you cannot — probing our private network, or reading our cloud credentials off the metadata endpoint. That's server-side request forgery, and the check exists to close it.

json
{
  "message": "The url must be a public HTTPS URL — loopback, private and metadata addresses are not allowed.",
  "code": "validation_error",
  "errors": {
    "url": ["The url must be a public HTTPS URL — loopback, private and metadata addresses are not allowed."]
  },
  "request_id": "8f2c1e64-1b17-4f8c-9f0e-3b6c1e0f5c31"
}

Outside production the check is relaxed, so a development webhook can point at localhost while you build the handler.

Unknown event types are rejected

Each entry in events must be one of the types in the event catalog. A typo is a 422, not a webhook that quietly never fires:

json
{
  "message": "Unknown event type. See the event types list in the API reference.",
  "code": "validation_error",
  "errors": {
    "events.0": ["Unknown event type. See the event types list in the API reference."]
  },
  "request_id": "8f2c1e64-1b17-4f8c-9f0e-3b6c1e0f5c31"
}

A Sending-access key answers 403 — it is a real key, it just isn't allowed here. See Errors.

Transactional email on your own domain.