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, campaigns, and custom field definitions (POST and PUT). Also required to add group members and declare an A/B winner.
deletepermissionNoDelete contacts, groups, templates, campaigns, and custom field definitions, 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 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.

NameTypeRequiredDescription
Authenticated10,000/hourNoPer API token, on both the GoodPostal and Nonprofit plans
Subscribe300/minuteNoPer API token, on POST /contacts/subscribe.
Batch subscribe60/minuteNoPer 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.
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 stay with the address
Once someone unsubscribes or their address hard bounces, that address stays opted out, 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 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.

NameTypeRequiredDescription
searchstringNoSearch by email, first name, or last name
group_iduuidNoFilter by contact group
subscribedbooleanNoFilter by subscription status
not_emailablebooleanNoFilter by whether GoodPostal stopped sending to the address on its own, after one permanent bounce or three temporary ones in a row
statestringNoFilter by state/province
citystringNoFilter by city
zip_codestringNoFilter by zip/postal code
countrystringNoFilter by country
metadata[key][operator]stringNoFilter by custom field, for example metadata[sponsor_count][gt]=3. See Custom fields below.
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. A contact created without one is stored as US.
metadataobjectNoCustom fields, replacing any already on the contact. See Custom fields below.
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": "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.

NameTypeRequiredDescription
emailstringYesEmail address used to find or create the contact
first_namestringNoFirst name
last_namestringNoLast name
phonestringNoPhone number (max 50 characters)
address_line_1stringNoStreet address line 1
address_line_2stringNoStreet address line 2
citystringNoCity
statestringNoState or province
zip_codestringNoZip or postal code
countrystringNoCountry. A contact created without one is stored as US.
metadataobjectNoCustom fields to merge into the contact. See Custom fields below.
group_idsuuid[]NoArray of contact group IDs to add this contact to (added, not synced)
consentobjectNoEvidence 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_onlybooleanNotrue = 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.

Resubscribing an opted-out address
An address with no opt-out history is subscribed immediately. An address that previously unsubscribed or hard bounced stays unsubscribed unless the request includes a 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.

Example request
bash
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"
       }
     }'
Response
json
{
  "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.

NameTypeRequiredDescription
contactsarrayYesBetween 1 and 500 objects, each in exactly the same shape as the single subscribe request above
Warning
Validation is all or nothing. If any row is invalid the whole request is rejected with 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.
bash
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.

json
{
  "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
  }
}
Note
If your plan contact limit runs out partway through, the remaining new contacts come back as 403 rows while updates to contacts you already have keep working. Read summary.refused to spot it.

Custom fields

Note
Create your custom fields in Settings > Custom Fields to get a ready-made payload and curl command for your own workspace, with every field filled in. Definitions are labels and types only; they never gate what the API accepts.

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.

NameTypeRequiredDescription
Shapeflat objectNoKeys and values only, no nesting. A nested object or a list as a value is rejected.
KeysstringNoMust 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.
Valuesstring | number | booleanNoText (max 1000 characters), a number, or true/false.
Limit50 keysNoA 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 mergebehaviorNoPOST /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 valuesspecialNoOn 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.
Example
json
{
  "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.

Example request
bash
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.

Warning
You are responsible for having the right to store what you send. Do not send payment card numbers, health information, or data about children.

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.

NameTypeRequiredDescription
keystringYesThe JSON key your system sends, following the custom field key rules above. Unique within the workspace, and fixed once created.
labelstringYesThe human name shown in the dashboard, max 100 characters, for example "Sponsor count".
typestringYesOne of text, number, boolean, or date. Drives how the dashboard filters and displays the value.
descriptionstringNoOptional note for your team, max 255 characters.
display_formatstringNoOptional. 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_orderintegerNoRead-only. Returned so you can present definitions in the same order the dashboard does.
Example request
bash
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"
     }'
Example response
json
{
  "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.

Note
Deleting a definition removes the description only. The contacts that carry the key keep their values, and the key keeps working in filters and exports. To delete the stored values too, send 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

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 email 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.