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, campaigns, and custom field definitions (POST and PUT). Also required to add group members and declare an A/B winner. |
delete | permission | No | Delete contacts, groups, templates, campaigns, and custom field definitions, 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 hourly budget counted per API token, applied to reads, writes, and deletes alike. The subscribe endpoints carry a second, per-minute ceiling on top of it, so a burst of signups cannot spend your whole hourly budget in a few seconds.
| Name | Type | Required | Description |
|---|---|---|---|
Authenticated | 10,000/hour | No | Per API token, on both the GoodPostal and Nonprofit plans |
Subscribe | 300/minute | No | Per API token, on POST /contacts/subscribe. |
Batch subscribe | 60/minute | No | Per API token, on POST /contacts/subscribe/batch. A batch call is one request however many contacts it carries, up to 500, so 60 calls a minute is up to 30,000 contacts a minute. |
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 through POST /contacts or PUT /contacts/{id} is refused with a 422, and so is changing a contact's email to one; AI assistants cannot lift an opt-out either.One API path can lift an unsubscribe: POST /contacts/subscribe, and only when the request carries a consent block dated within the last 24 hours and after the opt-out on record. That is for systems of your own that collected a fresh opt-in, such as a checkout form or a donation page. A hard bounce is never lifted by any API path. Away from the API, the ways back are unchanged: 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 |
not_emailable | boolean | No | Filter by whether GoodPostal stopped sending to the address on its own, after one permanent bounce or three temporary ones in a row |
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 |
metadata[key][operator] | string | No | Filter by custom field, for example metadata[sponsor_count][gt]=3. See Custom fields below. |
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. A contact created without one is stored as US. |
metadata | object | No | Custom fields, replacing any already on the contact. See Custom fields below. |
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": "US"
},
"metadata": {"source": "website"},
"subscribed": true,
"unsubscribed_at": null,
"bounce_count": 0,
"complaint_count": 0,
"hard_bounced_at": null,
"soft_bounce_stopped_at": null,
"not_emailable": false,
"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"
}
}Subscribe (upsert)
POST /contacts/subscribe
Finds a contact by email and creates or updates it in one call. Requires a token with the write permission. Omitted and null fields never blank a value already on file, so you can send only what your own system knows without reading the contact back first. Sending the same request again is safe: it returns the same contact, does not add a duplicate group membership, and does not write a duplicate consent record.
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address used to find or create the contact |
first_name | string | No | First name |
last_name | string | No | Last name |
phone | string | No | Phone number (max 50 characters) |
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. A contact created without one is stored as US. |
metadata | object | No | Custom fields to merge into the contact. See Custom fields below. |
group_ids | uuid[] | No | Array of contact group IDs to add this contact to (added, not synced) |
consent | object | No | Evidence of opt-in: source, text, at, and an optional ip. To resubscribe an opted-out address, at must be within the last 24 hours and after any opt-out on record. See below. |
existing_only | boolean | No | true = update an existing contact only. An email you do not already hold returns 404 and nothing is created: no contact, no plan capacity used, no consent record. Use it for enrichment syncs that should never grow your list. |
A consent block, when sent, needs a source (where the consent was collected, max 100 characters), the exact text the person agreed to (max 500 characters), and at (when they agreed, as a date or timestamp). An empty consent object or one missing any of those three is rejected. A consent block is stored as evidence even when it does not change the contact's subscription state, but identity is the at instant: one record per address per instant, compared in UTC. A second block for the same address and the same instant is treated as a replay and is not stored again, even if its wording, source, or IP address differs.
consent block whose at is within the last 24 hours and falls after the opt-out on record (a 5 minute allowance covers clock drift between systems). Consent collected before someone opted out does not undo the opt-out that followed it. That history outlives the contact record, so an address you have never sent to before can still be created unsubscribed. A hard bounce can never be lifted through this endpoint, regardless of consent; the address needs to confirm a new subscription through a form, or an administrator needs to re-enable it in the dashboard.The response includes a meta object: created is true for a new contact and false for an update, and resubscribe_refused_reason is null when the contact ends up subscribed, or one of consent_required or hard_bounced when it does not. A new contact returns 201; an update returns 200.
curl -X POST https://goodpostal.com/api/v1/contacts/subscribe \
-H "Authorization: Bearer gp_live_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"first_name": "Jane",
"metadata": {"sponsor_count": 3, "recurring_donor": true},
"consent": {
"source": "checkout_form",
"text": "I agree to receive email updates.",
"at": "2026-08-27T14:00:00Z"
}
}'{
"data": {
"id": 1,
"email": "jane@example.com",
"first_name": "Jane",
"last_name": null,
"phone": null,
"address": {
"line_1": null,
"line_2": null,
"city": null,
"state": null,
"zip_code": null,
"country": "US"
},
"metadata": {"sponsor_count": 3, "recurring_donor": true},
"subscribed": true,
"unsubscribed_at": null,
"bounce_count": 0,
"complaint_count": 0,
"hard_bounced_at": null,
"soft_bounce_stopped_at": null,
"not_emailable": false,
"groups": [],
"created_at": "2026-08-27T14:00:05+00:00",
"updated_at": "2026-08-27T14:00:05+00:00"
},
"meta": {
"created": true,
"resubscribe_refused_reason": null
}
}Subscribe (batch)
POST /contacts/subscribe/batch
Sends up to 500 contacts in one call. Every row behaves exactly like the single subscribe above, existing_only and consent included, and every row is processed independently: a row that is missing or refused never undoes the rows around it. The whole call counts as one request against your rate limits, so 500 contacts cost the same as one.
| Name | Type | Required | Description |
|---|---|---|---|
contacts | array | Yes | Between 1 and 500 objects, each in exactly the same shape as the single subscribe request above |
422 and nothing is written, so you never end up with half a batch applied. Errors are keyed by row, for example contacts.1.email.curl -X POST https://goodpostal.com/api/v1/contacts/subscribe/batch \
-H "Authorization: Bearer gp_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{"email": "amara@example.com", "first_name": "Amara"},
{"email": "returning@example.com", "existing_only": true},
{
"email": "sponsor@example.com",
"metadata": {"sponsor_count": 3},
"consent": {
"source": "website signup form",
"text": "Yes, send me email updates.",
"at": "2026-08-27T14:05:00Z"
}
}
]
}'The response reports one result per row, in the order you sent them, plus a summary you can log or alert on. Each row carries the status the single endpoint would have returned: 201 created, 200 updated, 404 when existing_only found no contact, and 403 when your plan contact limit was reached.
{
"results": [
{"index": 0, "email": "amara@example.com", "status": 201, "created": true, "resubscribe_refused_reason": null},
{"index": 1, "email": "returning@example.com", "status": 404, "message": "No contact with that email exists in this workspace."},
{"index": 2, "email": "sponsor@example.com", "status": 200, "created": false, "resubscribe_refused_reason": null}
],
"summary": {
"created": 1,
"updated": 1,
"missing": 1,
"refused": 0
}
}403 rows while updates to contacts you already have keep working. Read summary.refused to spot it.Custom fields
Custom fields (the metadata parameter) let you attach your own data to a contact, such as a donor tier, an ID from another system, or a sponsor count. There is no schema to define ahead of time; any key that follows the rules below is accepted. The same rules apply everywhere custom fields are written: POST /contacts, PUT /contacts/{id}, and POST /contacts/subscribe.
| Name | Type | Required | Description |
|---|---|---|---|
Shape | flat object | No | Keys and values only, no nesting. A nested object or a list as a value is rejected. |
Keys | string | No | Must start with a lowercase letter, then up to 63 more lowercase letters, numbers, or underscores, for example sponsor_count or crm_id. A key made only of digits is not accepted. |
Values | string | number | boolean | No | Text (max 1000 characters), a number, or true/false. |
Limit | 50 keys | No | A contact may hold at most 50 custom fields. On the subscribe endpoint the cap is checked against the merged result, so a call that would push the total over 50 is rejected and nothing is written. |
Replace or merge | behavior | No | POST /contacts and PUT /contacts/{id} replace the whole custom fields object with what you send. POST /contacts/subscribe merges key by key and leaves keys you do not mention alone. |
null values | special | No | On the subscribe endpoint, send null to remove a key, omit it to leave it alone. An empty string is treated as null, so it removes the key too. On the create and update endpoints, which replace the whole object, a null is stored as a null. |
{
"metadata": {
"sponsor_count": 3,
"recurring_donor": true,
"crm_id": "sf_0041T"
}
}Filter contacts by custom field on GET /contacts with metadata[key][operator]=value. Operators are eq, neq, contains, gt, gte, lt, lte, exists, and not_exists. exists and not_exists take no value. Up to 10 conditions may be combined on one request, and all of them must match.
gt, gte, lt, and lte compare numbers against stored numbers, and dates against stored ISO 8601 date strings such as 2026-08-27 or 2026-08-27T14:00:00+00:00. The comparison follows the shape of the value you send, so a date range works only when the stored values are ISO 8601 too. Store dates in that format and they sort correctly as text. A range comparison against a value that is neither a number nor an ISO 8601 date, such as gt=gold, matches no contacts.
curl -g "https://goodpostal.com/api/v1/contacts?metadata[sponsor_count][gt]=3&metadata[country][eq]=Belize" \
-H "Authorization: Bearer gp_live_your_token_here" \
-H "Accept: application/json"The -g flag turns off curl's own handling of [ and ], which it otherwise reads as a range and rejects. Other clients need no equivalent.
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). If metadata is provided, it replaces the contact's custom fields the same way. Use POST /contacts/subscribe when you want to change some custom fields and leave the rest alone.
Delete a contact
DELETE /contacts/{id}
Moves a contact to Trash and removes them from all groups. It can be restored from the dashboard within 30 days; after that it is deleted permanently. Requires confirm: true in the request body.
Custom Field Definitions
Definitions describe your custom field keys: the words your team sees in the dashboard and the kind of value each key holds. They are optional and they never gate what the API accepts. A key with no definition is still stored and still filterable, it just shows as its raw key. A workspace may define up to 50 fields, matching the 50 custom fields a contact may hold.
List definitions
GET /custom-fields
Returns this workspace's definitions, ordered by sort order then key.
Create a definition
POST /custom-fields
Requires the write permission.
| Name | Type | Required | Description |
|---|---|---|---|
key | string | Yes | The JSON key your system sends, following the custom field key rules above. Unique within the workspace, and fixed once created. |
label | string | Yes | The human name shown in the dashboard, max 100 characters, for example "Sponsor count". |
type | string | Yes | One of text, number, boolean, or date. Drives how the dashboard filters and displays the value. |
description | string | No | Optional note for your team, max 255 characters. |
display_format | string | No | Optional. How the value prints in an email. For a number field: plain, integer, or currency. For a date field: a PHP date format such as "F Y". Must be null for text and boolean fields. Leave it out to use the workspace default. |
sort_order | integer | No | Read-only. Returned so you can present definitions in the same order the dashboard does. |
curl -X POST https://goodpostal.com/api/v1/custom-fields \
-H "Authorization: Bearer gp_live_your_token_here" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"key": "sponsor_count",
"label": "Sponsor count",
"type": "number",
"description": "How many children this donor sponsors",
"display_format": "integer"
}'{
"data": {
"id": 12,
"key": "sponsor_count",
"label": "Sponsor count",
"type": "number",
"description": "How many children this donor sponsors",
"display_format": "integer",
"sort_order": 0,
"created_at": "2026-08-27T14:00:05+00:00"
}
}Get, update, or delete a definition
GET /custom-fields/{id}, PUT /custom-fields/{id}, and DELETE /custom-fields/{id}
Updating accepts label, type, description, and display_format. The key cannot change, because your contacts already carry it. Updating requires the write permission and deleting requires delete.
purge_data: true in the delete body: that removes the key from every contact in the workspace and cannot be undone.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 email 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 |