API Reference

The GoodPostal REST API gives you programmatic access to contacts, contact groups, templates, campaigns, senders, webhooks, and account data. Use it to integrate GoodPostal into your applications or to build AI-powered email workflows.

Authentication

All API requests require a Bearer token. Generate tokens from your dashboard under Settings > API Keys. Tokens use a gp_live_ prefix so they are easy to identify in your code.

Authentication header
bash
curl -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Accept: application/json" \
     https://goodpostal.com/api/v1/contacts

Include Accept: application/json on every request to ensure you receive JSON responses.

Token Permissions

Each token is granted a set of permissions when you create it. Every request needs the baseline read permission, and each write, delete, or webhook operation additionally requires its matching permission. A token that lacks the required permission receives a 403 response.

NameTypeRequiredDescription
readbaselineNoRequired on every request. Grants all GET endpoints.
writepermissionNoCreate and update contacts, groups, templates, and campaigns (POST and PUT). Also required to add group members and declare an A/B winner.
deletepermissionNoDelete contacts, groups, templates, and campaigns, and remove group members (DELETE).
webhookspermissionNoCreate, update, and delete webhook subscriptions.

Base URL

text
https://goodpostal.com/api/v1

Rate Limits

Every endpoint shares a single rate limit that is counted per API token, per hour. The same limit applies to reads, writes, and deletes alike; there is no separate per-endpoint or per-minute limit.

NameTypeRequiredDescription
Authenticated10,000/hourNoPer API token, on both the GoodPostal and Nonprofit plans
UnauthenticatedRejectedNoRequests without a valid token are rejected with a 401 before any quota is consumed
Note
The limit is counted per token, not per workspace. Issuing multiple tokens multiplies your effective throughput.

The current limit and how many requests remain are returned on every response. When you exceed the limit you receive a 429 with a Retry-After header telling you how many seconds to wait.

text
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9955
Retry-After: 42

Response Format

All responses are JSON, except successful DELETE endpoints, which return HTTP 204 with an empty body. Single resources are wrapped in a data key. Paginated responses include meta with pagination details.

Single resource
json
{
  "data": {
    "id": 1,
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "subscribed": true,
    "created_at": "2026-03-14T00:00:00+00:00"
  }
}
Paginated collection
json
{
  "data": [ ... ],
  "links": {
    "first": "https://goodpostal.com/api/v1/contacts?page=1",
    "last": "https://goodpostal.com/api/v1/contacts?page=5",
    "prev": null,
    "next": "https://goodpostal.com/api/v1/contacts?page=2"
  },
  "meta": {
    "current_page": 1,
    "last_page": 5,
    "per_page": 25,
    "total": 120
  }
}
Error response
json
{
  "message": "The given data was invalid.",
  "errors": {
    "email": ["The email field is required."]
  }
}

Confirmation Pattern

All DELETE endpoints require a confirm: true field in the request body. If omitted, the API returns a 409 Conflict response asking you to confirm. This prevents accidental deletions.

Delete with confirmation
bash
curl -X DELETE https://goodpostal.com/api/v1/contacts/42 \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Content-Type: application/json" \
     -d '{"confirm": true}'

Contacts

Contacts represent individual email recipients in your workspace. Contact IDs are integers.

Opt-outs are permanent
Once someone unsubscribes or their address hard bounces, that address stays opted out for good, and the record outlives the contact row. Creating a contact on such an address succeeds, but the contact is created unsubscribed and no campaign will reach it. Setting subscribed: true on one of those addresses is refused with a 422, and so is changing a contact's email to one. The only ways back are the recipient opting in again through a confirmed subscription form, or an administrator re-enabling them in the dashboard.

List contacts

GET /contacts

Returns a paginated list of contacts with optional filtering.

NameTypeRequiredDescription
searchstringNoSearch by email, first name, or last name
group_iduuidNoFilter by contact group
subscribedbooleanNoFilter by subscription status
statestringNoFilter by state/province
citystringNoFilter by city
zip_codestringNoFilter by zip/postal code
countrystringNoFilter by country
per_pageintegerNoResults per page (default 25, max 100)
pageintegerNoPage number
Example request
bash
curl "https://goodpostal.com/api/v1/contacts?subscribed=true&per_page=10" \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Accept: application/json"

Create a contact

POST /contacts

NameTypeRequiredDescription
emailstringYesEmail address (must be unique within your workspace)
first_namestringNoFirst name
last_namestringNoLast name
phonestringNoPhone number
address_line_1stringNoStreet address line 1
address_line_2stringNoStreet address line 2
citystringNoCity
statestringNoState or province
zip_codestringNoZip or postal code
countrystringNoCountry code
metadataobjectNoCustom key-value pairs (max 50 keys)
subscribedbooleanNoSubscription status (default true)
group_idsuuid[]NoArray of contact group IDs to add this contact to
Example request
bash
curl -X POST https://goodpostal.com/api/v1/contacts \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Content-Type: application/json" \
     -d '{
       "email": "jane@example.com",
       "first_name": "Jane",
       "last_name": "Doe",
       "city": "Portland",
       "state": "OR",
       "metadata": {"source": "website"},
       "group_ids": ["550e8400-e29b-41d4-a716-446655440000"]
     }'
Response
json
{
  "data": {
    "id": 1,
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": null,
    "address": {
      "line_1": null,
      "line_2": null,
      "city": "Portland",
      "state": "OR",
      "zip_code": null,
      "country": null
    },
    "metadata": {"source": "website"},
    "subscribed": true,
    "unsubscribed_at": null,
    "bounce_count": 0,
    "complaint_count": 0,
    "groups": [{"id": "550e8400-e29b-41d4-a716-446655440000", "name": "Newsletter"}],
    "created_at": "2026-03-14T00:00:00+00:00",
    "updated_at": "2026-03-14T00:00:00+00:00"
  }
}

Get a contact

GET /contacts/{id}

Returns a single contact with their group memberships.

Update a contact

PUT /contacts/{id}

Accepts the same fields as creation. All fields are optional. If group_ids is provided, it fully syncs the contact's group memberships (replaces all existing groups).

Delete a contact

DELETE /contacts/{id}

Deletes a contact and removes them from all groups. The contact is soft-deleted and is permanently purged after 30 days. Requires confirm: true in the request body.

Contact Groups

Groups let you organize contacts into segments for targeted campaigns. Group IDs are UUIDs.

List groups

GET /groups

Returns a paginated list of groups, ordered by sort order then name.

Create a group

POST /groups

NameTypeRequiredDescription
namestringYesGroup name (max 255 characters)
descriptionstringNoGroup description (max 1000 characters)
colorstringNoDisplay color, e.g. "#3B82F6" (max 20 characters)

A URL-safe slug is generated automatically from the name.

Example request
bash
curl -X POST https://goodpostal.com/api/v1/groups \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Content-Type: application/json" \
     -d '{"name": "Newsletter Subscribers", "color": "#3B82F6"}'
Response
json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Newsletter Subscribers",
    "slug": "newsletter-subscribers",
    "description": null,
    "color": "#3B82F6",
    "contact_count": 0,
    "sort_order": 0,
    "created_at": "2026-03-14T00:00:00+00:00",
    "updated_at": "2026-03-14T00:00:00+00:00"
  }
}

Get a group

GET /groups/{id}

Update a group

PUT /groups/{id}

Same fields as creation, all optional.

Delete a group

DELETE /groups/{id}

Requires confirm: true. Contacts in the group are not deleted.

Add members to a group

POST /groups/{id}/members

Add contacts to a group by ID or by geographic filter. This operation is idempotent; contacts already in the group are skipped.

NameTypeRequiredDescription
contact_idsinteger[]NoArray of contact IDs to add
filterobjectNoGeographic filter with state, city, zip_code, and/or country fields

Provide either contact_ids or filter, not both.

Add by IDs
bash
curl -X POST https://goodpostal.com/api/v1/groups/550e8400-.../members \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Content-Type: application/json" \
     -d '{"contact_ids": [1, 2, 3]}'

Remove members from a group

DELETE /groups/{id}/members

NameTypeRequiredDescription
contact_idsinteger[]YesArray of contact IDs to remove
confirmbooleanYesMust be true

Templates

Templates define the design and content of your emails. Template IDs are UUIDs. Templates are soft-deleted, so deleting a template does not permanently remove it.

List templates

GET /templates

NameTypeRequiredDescription
statusstringNoFilter by status: "draft" or "published"
searchstringNoSearch by name
category_iduuidNoFilter by category
per_pageintegerNoResults per page (default 25, max 100)
Note
The list endpoint returns a compact resource without design_json. Use the single-template endpoint to get the full design data.

Create a template

POST /templates

NameTypeRequiredDescription
namestringYesTemplate name. Required unless you pass from_showcase, in which case the starter template's own name is used
descriptionstringNoTemplate description
subject_linestringNoDefault subject line
statusstringNo"draft" or "published" (default "draft")
design_jsonobjectNoTemplate design data (block structure)
category_iduuidNoCategory to assign the template to
duplicate_fromuuidNoID of an existing template to duplicate
from_showcasestringNoStarter catalog ID or stable number (for example, example-23, 5, starter-5, or template-5)
Example request
bash
curl -X POST https://goodpostal.com/api/v1/templates \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Content-Type: application/json" \
     -d '{
       "name": "March Newsletter",
       "subject_line": "Your March update is here",
       "status": "draft"
     }'

Get a template

GET /templates/{id}

Returns the full template resource including design_json and compiled html_content.

Update a template

PUT /templates/{id}

Same fields as creation, all optional.

Delete a template

DELETE /templates/{id}

Soft-deletes the template. Requires confirm: true.

List components

GET /templates/components

Returns all available template components grouped by category. This endpoint is not paginated.

Get a component

GET /templates/components/{slug}

Returns a specific component with its block data.

Browse showcase examples

GET /templates/showcase

Returns full-email showcase examples with their composition patterns. Use these as complete starting points rather than single components. Each entry includes a stable number; pass its ID or number to POST /templates as from_showcase. This endpoint is not paginated.

Get a showcase example

GET /templates/showcase/{id}

Returns a specific showcase example with its block data.

Campaigns

Campaigns tie together a template, a sender identity, and one or more contact groups. Campaign IDs are UUIDs.

Campaigns cannot be sent via API
The API creates campaigns as drafts only. To send a campaign, a team member must approve and launch it from the GoodPostal dashboard. This is a deliberate safety measure to prevent accidental mass sends.

List campaigns

GET /campaigns

NameTypeRequiredDescription
statusstringNoFilter by status (draft, scheduled, sending, sent, etc.)
searchstringNoSearch by name
per_pageintegerNoResults per page (default 25, max 100)

Create a campaign

POST /campaigns

NameTypeRequiredDescription
namestringYesCampaign name
descriptionstringNoInternal description
subject_linestringNoEmail subject line. Required for a regular campaign; omit when supplying variants for an A/B test.
preview_textstringNoPreview text shown in email clients
template_iduuidNoID of the template to use. Required for a regular campaign; omit when supplying variants.
sender_identity_idintegerNoID of the sender identity. Required for a regular campaign; omit when supplying variants.
reply_to_emailstringNoReply-to email address
send_to_allbooleanNoSend to every subscribed contact. Mutually exclusive with contact_group_ids.
contact_group_idsuuid[]NoContact groups to send to. Omit when send_to_all is true.
variantsobject[]NoFor an A/B test, supply two or more variants instead of the top-level subject_line, template_id, and sender_identity_id. Each variant sets its own label, template_id, subject_line, sender_identity_id, and optional contact groups.
Example request
bash
curl -X POST https://goodpostal.com/api/v1/campaigns \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Content-Type: application/json" \
     -d '{
       "name": "March Newsletter",
       "subject_line": "Your March update is here",
       "template_id": "550e8400-e29b-41d4-a716-446655440000",
       "sender_identity_id": 1,
       "contact_group_ids": ["660e8400-e29b-41d4-a716-446655440000"]
     }'

Get a campaign

GET /campaigns/{id}

Returns the campaign with its sender identity and contact groups.

Update a campaign

PUT /campaigns/{id}

Same fields as creation, all optional. Only draft and scheduled campaigns can be updated. If contact_group_ids is provided, it fully syncs the campaign's groups.

Delete a campaign

DELETE /campaigns/{id}

Only draft campaigns can be deleted. Requires confirm: true.

Get campaign statistics

GET /campaigns/{id}/stats

Returns delivery and engagement statistics for a campaign: delivery, open, click, bounce, unsubscribe, and complaint counts and rates, unique opens and clicks, the top clicked links, and A/B variant results. Full analytics are included on every GoodPostal plan.

Declare an A/B test winner

POST /campaigns/{id}/declare-winner

Manually declares the winning variant of an A/B test campaign. Only valid for A/B test campaigns.

If the campaign is already sending, the response includes requires_resume_in_dashboard: true and the remaining recipients are not sent from the API. Open the campaign in the GoodPostal dashboard to resume the winner cohort. This matches the rule that campaigns are only ever sent from the dashboard.

If a winner has already been determined, the call returns 409 Conflict naming the existing winner, and that winner stands. You also get a 409 if a winner determination is already in progress; retry shortly.

NameTypeRequiredDescription
winnerstringYesSingle lowercase variant label, e.g. "a" or "b"

Senders

Sender identities represent the "from" addresses used in your campaigns. The senders endpoint is read-only. Sender IDs are integers.

List senders

GET /senders

Returns all sender identities for your workspace, ordered with the default sender first. This endpoint is not paginated.

Example request
bash
curl https://goodpostal.com/api/v1/senders \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Accept: application/json"

Get a sender

GET /senders/{id}

Returns a single sender identity with its associated email sending service details.

Webhook Subscriptions

Webhooks let you receive real-time notifications when events occur in your workspace.

Available events

NameTypeRequiredDescription
contact.unsubscribedeventNoA contact unsubscribed
contact.resubscribedeventNoA previously unsubscribed contact opted back in
contact.bouncedeventNoAn email to a contact bounced
contact.complainedeventNoA contact marked an email as spam
campaign.senteventNoA campaign finished sending and at least one email reached the sending service
campaign.completedeventNoA campaign finished but no emails were sent successfully, so it is marked failed
campaign.pausedeventNoA campaign was paused
Note
Neither campaign.sent nor campaign.completed fires when a campaign starts sending. They fire at the end and are mutually exclusive: a campaign that sent at least one email emits campaign.sent, and one that sent none emits campaign.completed. Listen for campaign.sent to detect a successful finish.

List webhooks

GET /webhooks

Returns all webhook subscriptions. Not paginated.

Create a webhook

POST /webhooks

NameTypeRequiredDescription
urlstringYesHTTPS endpoint URL to receive events
eventsstring[]YesArray of event names to subscribe to
is_activebooleanNoWhether the webhook is active (default true)
Save your webhook secret
The webhook signing secret is returned only in the creation response. It is not included in subsequent GET requests. Store it securely; you will need it to verify webhook signatures.
Example request
bash
curl -X POST https://goodpostal.com/api/v1/webhooks \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Content-Type: application/json" \
     -d '{
       "url": "https://example.com/webhooks/goodpostal",
       "events": ["contact.unsubscribed", "campaign.completed"]
     }'

Get a webhook

GET /webhooks/{id}

Update a webhook

PUT /webhooks/{id}

Same fields as creation, all optional.

Delete a webhook

DELETE /webhooks/{id}

Requires confirm: true.

Account

Read-only endpoints for workspace information, usage data, and brand settings.

Get workspace info

GET /account

Returns your workspace name, slug, current plan, and timezone.

Example request
bash
curl https://goodpostal.com/api/v1/account \
     -H "Authorization: Bearer gp_live_your_token_here" \
     -H "Accept: application/json"

Get usage data

GET /account/usage

Returns your plan limits and current usage for contacts, storage, sends, seats, and features.

Get brand settings

GET /account/brand

Returns your workspace brand guidelines including colors, logo URL, fonts, social links, footer text, and button styles.

Guide

These endpoints return structured, LLM-friendly content designed for AI agents integrating with GoodPostal.

API guide

GET /guide

Returns a comprehensive guide to using the GoodPostal API, formatted for consumption by AI assistants and agents.

Template guide

GET /guide/template

Returns the template creation guide with block format, styling patterns, container composition, dark mode theming, and merge tags. Call before creating or updating templates.

OpenAPI specification

GET /openapi.json

Returns the full OpenAPI 3.1 specification for the GoodPostal API. Use this to generate client libraries or import into API tools like Postman.

Pagination

Paginated endpoints accept per_page (default 25, max 100) and page query parameters. The response includes links and meta objects with navigation URLs and page information.

Some endpoints (senders, webhooks, components) return all results without pagination.

Error Codes

NameTypeRequiredDescription
400Bad RequestNoInvalid request body or parameters
401UnauthorizedNoMissing or invalid API token
403ForbiddenNoToken does not have permission for this action
404Not FoundNoResource does not exist
409ConflictNoDELETE request missing confirm: true, or a conflicting state such as an A/B winner that has already been determined
422Validation ErrorNoRequest body failed validation
429Rate LimitedNoToo many requests; wait and retry after the X-RateLimit-Reset timestamp

Join our newsletter

Keep up with the latest from GoodPostal. No spam, just the good stuff.

We care about your data. Read our privacy policy.