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.
curl -H "Authorization: Bearer gp_live_your_token_here" \
-H "Accept: application/json" \
https://goodpostal.com/api/v1/contactsInclude 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.
| Name | Type | Required | Description |
|---|---|---|---|
read | baseline | No | Required on every request. Grants all GET endpoints. |
write | permission | No | Create and update contacts, groups, templates, and campaigns (POST and PUT). Also required to add group members and declare an A/B winner. |
delete | permission | No | Delete contacts, groups, templates, and campaigns, and remove group members (DELETE). |
webhooks | permission | No | Create, update, and delete webhook subscriptions. |
Base URL
https://goodpostal.com/api/v1Rate 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.
| Name | Type | Required | Description |
|---|---|---|---|
Authenticated | 10,000/hour | No | Per API token, on both the GoodPostal and Nonprofit plans |
Unauthenticated | Rejected | No | Requests without a valid token are rejected with a 401 before any quota is consumed |
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.
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9955
Retry-After: 42Response 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.
{
"data": {
"id": 1,
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"subscribed": true,
"created_at": "2026-03-14T00:00:00+00:00"
}
}{
"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
}
}{
"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.
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.
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.
| Name | Type | Required | Description |
|---|---|---|---|
search | string | No | Search by email, first name, or last name |
group_id | uuid | No | Filter by contact group |
subscribed | boolean | No | Filter by subscription status |
state | string | No | Filter by state/province |
city | string | No | Filter by city |
zip_code | string | No | Filter by zip/postal code |
country | string | No | Filter by country |
per_page | integer | No | Results per page (default 25, max 100) |
page | integer | No | Page number |
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
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address (must be unique within your workspace) |
first_name | string | No | First name |
last_name | string | No | Last name |
phone | string | No | Phone number |
address_line_1 | string | No | Street address line 1 |
address_line_2 | string | No | Street address line 2 |
city | string | No | City |
state | string | No | State or province |
zip_code | string | No | Zip or postal code |
country | string | No | Country code |
metadata | object | No | Custom key-value pairs (max 50 keys) |
subscribed | boolean | No | Subscription status (default true) |
group_ids | uuid[] | No | Array of contact group IDs to add this contact to |
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"]
}'{
"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
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Group name (max 255 characters) |
description | string | No | Group description (max 1000 characters) |
color | string | No | Display color, e.g. "#3B82F6" (max 20 characters) |
A URL-safe slug is generated automatically from the name.
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"}'{
"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.
| Name | Type | Required | Description |
|---|---|---|---|
contact_ids | integer[] | No | Array of contact IDs to add |
filter | object | No | Geographic filter with state, city, zip_code, and/or country fields |
Provide either contact_ids or filter, not both.
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
| Name | Type | Required | Description |
|---|---|---|---|
contact_ids | integer[] | Yes | Array of contact IDs to remove |
confirm | boolean | Yes | Must 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
| Name | Type | Required | Description |
|---|---|---|---|
status | string | No | Filter by status: "draft" or "published" |
search | string | No | Search by name |
category_id | uuid | No | Filter by category |
per_page | integer | No | Results per page (default 25, max 100) |
design_json. Use the single-template endpoint to get the full design data.Create a template
POST /templates
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Template name. Required unless you pass from_showcase, in which case the starter template's own name is used |
description | string | No | Template description |
subject_line | string | No | Default subject line |
status | string | No | "draft" or "published" (default "draft") |
design_json | object | No | Template design data (block structure) |
category_id | uuid | No | Category to assign the template to |
duplicate_from | uuid | No | ID of an existing template to duplicate |
from_showcase | string | No | Starter catalog ID or stable number (for example, example-23, 5, starter-5, or template-5) |
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.
List campaigns
GET /campaigns
| Name | Type | Required | Description |
|---|---|---|---|
status | string | No | Filter by status (draft, scheduled, sending, sent, etc.) |
search | string | No | Search by name |
per_page | integer | No | Results per page (default 25, max 100) |
Create a campaign
POST /campaigns
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Campaign name |
description | string | No | Internal description |
subject_line | string | No | Email subject line. Required for a regular campaign; omit when supplying variants for an A/B test. |
preview_text | string | No | Preview text shown in email clients |
template_id | uuid | No | ID of the template to use. Required for a regular campaign; omit when supplying variants. |
sender_identity_id | integer | No | ID of the sender identity. Required for a regular campaign; omit when supplying variants. |
reply_to_email | string | No | Reply-to email address |
send_to_all | boolean | No | Send to every subscribed contact. Mutually exclusive with contact_group_ids. |
contact_group_ids | uuid[] | No | Contact groups to send to. Omit when send_to_all is true. |
variants | object[] | No | For 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. |
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.
| Name | Type | Required | Description |
|---|---|---|---|
winner | string | Yes | Single 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.
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
| Name | Type | Required | Description |
|---|---|---|---|
contact.unsubscribed | event | No | A contact unsubscribed |
contact.resubscribed | event | No | A previously unsubscribed contact opted back in |
contact.bounced | event | No | An email to a contact bounced |
contact.complained | event | No | A contact marked an email as spam |
campaign.sent | event | No | A campaign finished sending and at least one email reached the sending service |
campaign.completed | event | No | A campaign finished but no emails were sent successfully, so it is marked failed |
campaign.paused | event | No | A campaign was paused |
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
| Name | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS endpoint URL to receive events |
events | string[] | Yes | Array of event names to subscribe to |
is_active | boolean | No | Whether the webhook is active (default true) |
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.
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
| Name | Type | Required | Description |
|---|---|---|---|
400 | Bad Request | No | Invalid request body or parameters |
401 | Unauthorized | No | Missing or invalid API token |
403 | Forbidden | No | Token does not have permission for this action |
404 | Not Found | No | Resource does not exist |
409 | Conflict | No | DELETE request missing confirm: true, or a conflicting state such as an A/B winner that has already been determined |
422 | Validation Error | No | Request body failed validation |
429 | Rate Limited | No | Too many requests; wait and retry after the X-RateLimit-Reset timestamp |