Plans API
The public list of plans, prices and limits and, for the owner and admins, the sending plan's Sends add-on and the marketing plan's Contacts add-on.
The public list of plans, the same one the pricing page uses. It does not need an API key. Further down, the sending plan's Sends add-on and the marketing plan's Contacts add-on, which the owner and admins manage with the dashboard session.
#List plans
GET/plans
Active, public plans, in display order, for both products: kind is transactional (sending) or marketing (contacts and broadcasts). Prices in prices are in cents, by currency (brl, usd and eur) and interval (month, year); overage_prices is the price per thousand beyond the limit, when the plan allows it (allows_overage): emails beyond the monthly quota on sending plans and contacts beyond the limit on marketing plans (overage_unit says which). In limits, null means unlimited, 0 means not included and rate_limit is the number of requests per second. The response also carries the feature_catalog, and the language of its texts follows the Accept-Language header (pt-BR or en); without it, the texts come in Portuguese.
Limits and features. limits carries every key of the plan's product, already resolved: numbers (null unlimited, 0 not included), on/off features (1 included, 0 not; postal_branding is inverted: 1 means it has the badge) and informative features. The feature_catalog describes each key: sections, each with key, label and features, and each feature with key, label, description, type (number or boolean), unit and unit_one (plural and singular, numbers only), inverted (only when true), kind (transactional, marketing or both) and enforced (the API already refuses what the plan does not include). See what each key blocks in Plan limits and features.
Bands set by Postal. The volume scale of the dashboard is made of bands that Postal registers for each product (volume_bands: {"transactional": [{"id", "volume"}], "marketing": [...]}, in the registered order; empty while the product has no bands). Each plan says which ones it serves: bands lists the volumes of the bands it serves, ascending, and each price in prices carries the volume_band_id of the band it serves (or null). A plan that does not serve a band simply does not sell that volume; above the last band only the custom plan serves. The volume of a price tied to a band is the band's. With no bands registered, what follows applies.
Volume tiers. A plan can have several prices per currency and interval, one for each tier: volume is the tier's monthly volume (emails on the sending plan, contacts on the marketing one; volume_unit says which) and replaces emails_per_month or contacts from limits for whoever subscribes to that tier. emails_per_month, inside a price, repeats volume on sending plans. overage_tiers lists each tier's overage price, and overage_prices summarizes the price by currency. The team turns the overage of each product on in the dashboard; every started pack of 1,000 goes on the next invoice. purchasable is false on a plan or price that cannot be bought (it is not linked to Stripe), and is_default marks the plan every team starts on. highlights is what the site's pricing card highlights: null (compact default) or {"inherit", "items"}, with inherit opening the card with “Everything in the previous plan, plus:” and items in order, each one {"type": "feature", "key"} or {"type": "text", "text"}.
Contacts add-on. contact_packs lists the Contacts add-on packs on sale for paid marketing plans: [{"object": "contact_pack", "id", "name", "contacts", "max_quantity", "interval", "prices": [{"currency", "amount"}]}], with prices in cents. The list is empty while none is on sale. See Contacts add-on.
cURL
curl https://api.postal.so/plans
Node.js
const response = await fetch('https://api.postal.so/plans');
const data = await response.json();
Python
import requests
response = requests.get(
"https://api.postal.so/plans",
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('plans');
$data = json_decode($response->getBody(), true);
{
"object": "list",
"data": [
{
"object": "plan",
"id": "0199e000-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"kind": "transactional",
"slug": "pro",
"name": "Pro",
"description": "For products in production with steady volume.",
"features": ["50,000 emails per month", "No daily limit", "10 domains"],
"highlights": {
"inherit": true,
"items": [
{
"type": "feature",
"key": "emails_per_month"
},
{
"type": "feature",
"key": "domains"
},
{
"type": "text",
"text": "Email support"
}
]
},
"limits": {
"emails_per_month": 50000,
"emails_per_day": null,
"domains": 10,
"inboxes": 10,
"custom_inbound_domains": 1,
"postal_branding": 0,
"team_members": 10,
"automation_runs": 10000,
"ai_credits": 100,
"layouts": null,
"rate_limit": 10,
"automation_experiments": 1,
"batch_sending": 1,
"scheduled_sending": 1,
"attachments_mb": null,
"engagement_tracking": 1,
"custom_domain_records": 1,
"test_addresses": 1,
"smtp_access": 1,
"shared_email_links": 1,
"inbox_agent": 1,
"templates": null,
"api_keys": null,
"webhooks": null,
"mcp_access": 1,
"exports": 1,
"retention_days": 30,
"automations": null,
"restricted_api_keys": 1,
"priority_support": 0,
"uptime_sla": 0,
"dedicated_manager": 0
},
"allows_overage": true,
"trial_days": 0,
"is_default": false,
"purchasable": true,
"volume_unit": "emails",
"overage_unit": "emails",
"bands": [50000, 100000],
"prices": [
{
"id": "0199e000-2b3c-7d4e-8f5a-6b7c8d9e0f1a",
"currency": "brl",
"interval": "month",
"amount": 9900,
"volume": 50000,
"volume_band_id": "0199e000-5e70-7b82-9d9e-0f1a2b3c4d5e",
"emails_per_month": 50000,
"purchasable": true
},
{
"id": "0199e000-2b3d-7e5f-9a6b-7c8d9e0f1a2b",
"currency": "usd",
"interval": "month",
"amount": 2000,
"volume": 50000,
"volume_band_id": "0199e000-5e70-7b82-9d9e-0f1a2b3c4d5e",
"emails_per_month": 50000,
"purchasable": true
},
{
"id": "0199e000-2b3e-7f60-8a7b-8c9d0e1f2a3b",
"currency": "brl",
"interval": "month",
"amount": 17900,
"volume": 100000,
"volume_band_id": "0199e000-5e71-7c93-8eaf-1a2b3c4d5e6f",
"emails_per_month": 100000,
"purchasable": true
},
{
"id": "0199e000-2b3f-7071-9b8c-9d0e1f2a3b4c",
"currency": "usd",
"interval": "month",
"amount": 3600,
"volume": 100000,
"volume_band_id": "0199e000-5e71-7c93-8eaf-1a2b3c4d5e6f",
"emails_per_month": 100000,
"purchasable": true
},
{
"id": "0199e000-2b40-7182-8c9d-0e1f2a3b4c5d",
"currency": "eur",
"interval": "month",
"amount": 1900,
"volume": 50000,
"volume_band_id": "0199e000-5e70-7b82-9d9e-0f1a2b3c4d5e",
"emails_per_month": 50000,
"purchasable": true
}
],
"overage_prices": {
"brl": 450,
"usd": 90,
"eur": 80
},
"overage_tiers": [
{
"volume": 50000,
"currency": "brl",
"amount": 450
},
{
"volume": 50000,
"currency": "usd",
"amount": 90
},
{
"volume": 50000,
"currency": "eur",
"amount": 80
},
{
"volume": 100000,
"currency": "brl",
"amount": 400
},
{
"volume": 100000,
"currency": "usd",
"amount": 80
}
]
},
{
"object": "plan",
"id": "0199e000-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"kind": "marketing",
"slug": "pro",
"name": "Pro",
"description": "For teams sending broadcasts and automations to a growing audience.",
"features": [],
"highlights": null,
"limits": {
"contacts": 5000,
"segments": null,
"broadcasts_per_month": null,
"email_verifications_per_month": 5000,
"crm_connections": 1,
"app_integrations": 3,
"dynamic_segments": 1,
"topics": null,
"forms": null,
"form_analytics": 1,
"priority_support": 0,
"uptime_sla": 0,
"dedicated_manager": 0
},
"allows_overage": true,
"trial_days": 0,
"is_default": false,
"purchasable": true,
"volume_unit": "contacts",
"overage_unit": "contacts",
"bands": [],
"prices": [
{
"id": "0199e000-4d5e-7f60-9b7c-8d9e0f1a2b3c",
"currency": "brl",
"interval": "month",
"amount": 14900,
"volume": 5000,
"emails_per_month": null,
"purchasable": true
}
],
"overage_prices": {
"brl": 700
},
"overage_tiers": [
{
"volume": 5000,
"currency": "brl",
"amount": 700
}
]
}
],
"volume_bands": {
"transactional": [
{
"id": "0199e000-5e6f-7a71-8c8d-9e0f1a2b3c4d",
"volume": 3000
},
{
"id": "0199e000-5e70-7b82-9d9e-0f1a2b3c4d5e",
"volume": 50000
},
{
"id": "0199e000-5e71-7c93-8eaf-1a2b3c4d5e6f",
"volume": 100000
}
],
"marketing": []
},
"feature_catalog": {
"object": "feature_catalog",
"sections": [
{
"key": "sending",
"label": "Sending and receiving",
"features": [
{
"key": "emails_per_month",
"label": "Emails per month",
"description": "Monthly sending quota (UTC month). When reached, the API refuses new sends unless the plan allows overage.",
"type": "number",
"unit": "emails",
"unit_one": "email",
"kind": "transactional",
"enforced": true
},
{
"key": "smtp_access",
"label": "SMTP sending",
"description": "Send through the SMTP server (smtp.postal.so) with an API key as the password.",
"type": "boolean",
"kind": "transactional",
"enforced": true
},
{
"key": "postal_branding",
"label": "“Sent with Postal” badge",
"description": "The Postal badge in sent emails and public forms. Usually off on paid plans.",
"type": "boolean",
"inverted": true,
"kind": "transactional",
"enforced": true
}
]
},
{
"key": "inboxes",
"…": "…"
}
]
},
"contact_packs": [
{
"object": "contact_pack",
"id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
"name": "Pack A",
"contacts": 10000,
"max_quantity": 10,
"interval": "month",
"prices": [
{
"currency": "brl",
"amount": 4900
},
{
"currency": "usd",
"amount": 900
}
]
}
]
}
#Sends add-on
When the team's sending plan has a monthly limit and offers the Sends add-on, the owner or an admin buys packs of extra sends with a one-time payment (it is not a subscription). The credits make up a reserve, apart from the plan's quota, and are only used once the monthly quota runs out. The dashboard guide shows the flow on screen: Sends add-on.
Dashboard session only
The two routes below use the dashboard session of a team owner or admin (GET /billing/email-credits also answers other members, but without the purchases and without the ability to buy). An API key, full-access or sending-only, gets 403 on the purchase. That is why the examples show the request the way the dashboard sends it, with no key header. To read the reserve with a key, use emails.credits of GET /usage.
- Order of use. The plan's monthly quota, then the reserve (the credits expiring soonest first, the ones without expiry last), then the overage, if the team turned it on (only the emails the reserve did not cover are billed), and then the refusal:
429 monthly_quota_exceeded. - One credit per email, counted the way the quota counts. Emails to the test addresses take no credit. A batch the reserve does not cover (with no overage on) is refused whole, without spending any credit.
- Broadcasts do not use the reserve, but they count in the same monthly quota.
- The plan's daily limit still applies: the reserve does not lift it, and the send gets
429 daily_quota_exceeded. - Validity. Each pack has its own, in days (
validity_days, counted from the payment confirmation), or none: withvalidity_days: 0,expires_atisnulland the credits never expire. - Refunds. Refunding the payment takes back only the credits that are still unused; what already left the reserve stays used.
- Who can buy. Only the owner and admins. A plan with no monthly limit has nothing to extend:
403plan_limit_reached. A team billed through a partner:403billed_by_partner. - Payment. Card and, when the pack's prices are in reais and Pix is on, Pix (
payment_methods). With Pix, the credits arrive when the payment is confirmed.
Where the reserve shows up in responses
| Route | Field | What it returns |
|---|---|---|
GET /usage | emails.credits | The reserve next to the quota: emails.monthly keeps the plan's used and limit. Sending-only keys see the balance, but not the grants list. |
GET /billing | emails.monthly, email_credits | The team summary, with the dashboard session only (owner or admin): see the table below. |
GET /billing/email-credits | the whole response | The view behind the Sends add-on section: the reserve, the packs on sale and the purchases. |
In GET /billing, the plan's quota and the reserve come apart:
| Field | What it tells |
|---|---|
emails.monthly | The plan's monthly quota: used, limit (null: unlimited) and resets_at. The credits are never added to it. |
email_credits | The reserve, in the same format as balance in GET /billing/email-credits: reserve, balance, used_this_month, in_use, next_expires_at and grants. |
#Get the team's Sends add-on
GET/billing/email-credits
What the Sends add-on section shows: the reserve, the packs on sale and, for the owner and admins, the purchase history.
available is false when billing is not up. included says whether the plan has a monthly sending limit the reserve can extend (false on an unlimited plan; then packs is empty). can_purchase is true for the owner and admins of a team billed by Postal, on a plan with a monthly limit. payment_methods lists card and, when Pix is on, pix.
balance is the reserve: balance (the credits that can be used now), used_this_month, in_use (true when the monthly quota is exhausted and there is a balance), next_expires_at (the first expiry, or null) and grants, the packs and bonuses with a balance in the order they are used; expires_at: null is a pack with no expiry. packs are the packs on sale: emails, amount (cents, in the primary currency), prices (one price per currency), validity_days (0: no expiry) and popular. purchases has the 50 most recent purchases and bonuses (empty for anyone who is not the owner or an admin): status is pending (Pix not paid yet), active, depleted, expired, failed, refunded or revoked; source is purchase or staff (a bonus); emails is what came in the pack and remaining what is left; receipt_url is the receipt, when there is one.
Errors
- 403
invalid_accessNo team is selected in the session.
GET /billing/email-credits HTTP/1.1
Host: api.postal.so
{
"object": "email_credits",
"available": true,
"included": true,
"can_purchase": true,
"payment_methods": ["card", "pix"],
"balance": {
"reserve": true,
"balance": 20000,
"used_this_month": 0,
"in_use": false,
"next_expires_at": "2027-01-15T12:00:00Z",
"grants": [
{
"id": "019a3f20-6c7d-7e8f-9a01-2b3c4d5e6f70",
"source": "purchase",
"name": "Pack A",
"emails": 20000,
"remaining": 20000,
"expires_at": "2027-01-15T12:00:00Z"
}
]
},
"packs": [
{
"object": "email_credit_pack",
"id": "019a3f20-5b6c-7d7e-8f90-1a2b3c4d5e6f",
"name": "Pack A",
"emails": 20000,
"amount": 4900,
"currency": "brl",
"prices": [
{
"currency": "brl",
"amount": 4900
},
{
"currency": "usd",
"amount": 900
}
],
"validity_days": 90,
"popular": true
},
{
"object": "email_credit_pack",
"id": "019a3f20-5b6d-7e8f-9a01-2b3c4d5e6f71",
"name": "Pack B",
"emails": 100000,
"amount": 19900,
"currency": "brl",
"prices": [
{
"currency": "brl",
"amount": 19900
},
{
"currency": "usd",
"amount": 3900
}
],
"validity_days": 0,
"popular": false
}
],
"purchases": [
{
"object": "email_credit_grant",
"id": "019a3f20-6c7d-7e8f-9a01-2b3c4d5e6f70",
"source": "purchase",
"status": "active",
"name": "Pack A",
"emails": 20000,
"remaining": 20000,
"amount": 4900,
"currency": "brl",
"validity_days": 90,
"expires_at": "2027-01-15T12:00:00Z",
"paid_at": "2026-10-17T12:00:00Z",
"refunded_at": null,
"receipt_url": "https://receipts.example.com/r/abc123",
"created_at": "2026-10-17T11:58:40Z"
}
]
}
#Buy a Sends add-on pack
POST/billing/email-credits/checkout
Creates the purchase of a pack and returns the address of the payment page (url): send the person there. The purchase does not exist as credit yet: the sends join the reserve when the payment is confirmed (right away by card; with Pix, when it is paid). The payment is one-time and uses the team's billing currency when the pack has a price in it; otherwise, the pack's primary currency.
When it finishes or is abandoned, the browser comes back to Settings › Billing in the dashboard with ?extra_sends=success (payment received) or ?extra_sends=canceled (purchase canceled, nothing charged). To know whether the credits are in yet, read GET /billing/email-credits again: the purchase shows in purchases as pending and becomes active. The route has a lower rate limit than the rest of the API, like the other routes that touch billing.
Request body
pack_idstringrequiredThe pack ID, as inpacks[].idofGET /billing/email-credits.
Errors
- 403
forbiddenThe caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session). - 403
billed_by_partnerThe team is billed through a partner, who handles the add-on. - 403
plan_limit_reachedThe plan has no monthly sending limit, so there is nothing to extend. - 422
validation_errorA missingpack_id, one that is not a UUID or does not exist, or a pack that is no longer for sale (the message says which). - 503
billing_unavailableBilling is not available right now.
POST /billing/email-credits/checkout HTTP/1.1
Host: api.postal.so
Content-Type: application/json
{
"pack_id": "019a3f20-5b6c-7d7e-8f90-1a2b3c4d5e6f"
}
{
"url": "https://checkout.example.com/c/pay/cs_abc123"
}
#Contacts add-on
When the team's marketing plan offers the Contacts add-on, the owner or an admin adds contacts to the limit without changing plans. Each pack has a recurring price, added to the marketing subscription's, and the contact limit becomes the plan's contacts + each pack's contacts × quantity. Packs only exist for a paid, active marketing plan, and only the ones Postal put on sale: GET /plans returns the ones on sale in contact_packs (empty when there are none). The dashboard guide shows the flow on screen: Contacts add-on.
Dashboard session only
The three routes below use the dashboard session of a team owner or admin. An API key, full-access or sending-only, gets 403, and so do the other team members. That is why the examples show the request the way the dashboard sends it, with no key header. To read the limit with the packs using a key, use GET /usage.
- Increasing. It is charged right away, prorated for the rest of the period, and then at every renewal, on the same invoice as the subscription. The limit only grows once the payment is confirmed: confirmed right away, the response is
200withaction: "updated"; if the payment needs confirmation (card authentication, for example), it is202withaction: "payment_pending"and theinvoice_urlof the invoice to pay, and the limit does not change until it is paid. Declined payment:422payment_failed, and nothing changes. - Reducing or removing. It takes effect at the next renewal, with no refund, and the limit stays the same until then (
action: "scheduled"). Asking for the current quantity again gives up the scheduled reduction (action: "reduction_canceled"). A reduction is refused with422downgrade_blocked(and theblockerslist) if the team has more contacts than the limit that would be left, and also if the subscription ends at the end of the period. - Several of the same pack. A team can have more than one, up to the pack's
max_quantity. - Who can. Only with a paid marketing plan and an active subscription. Without a paid subscription (Free plan), or with the subscription in a trial or with a pending payment:
403contact_packs_unavailable. A team billed through a partner:403billed_by_partner. The pack's currency and billing (monthly or yearly) must be the subscription's. - Plan and subscription. The packs stay when the plan changes (upgrade or scheduled change) and add to the new plan's limit. They end with the subscription, and the team goes back to the limit of the plan it ends up on. If the team's contact limit was adjusted individually, that number is the final one and the packs do not add to it.
Where the packs show up in responses
| Route | Field | What it returns |
|---|---|---|
GET /plans | contact_packs | The packs on sale, without the team's quantities: object, id, name, contacts (how many contacts each adds), max_quantity, interval and prices ([{"currency", "amount"}], in cents). |
GET /usage | contacts | limit is the limit in force (plan + packs); plan_limit, the plan's alone; packs has contacts (what the packs add), effective, scheduled and, for full-access keys, items. Sending-only keys see the totals, but not which packs the team has. |
GET /billing | contact_packs | The team summary, with the dashboard session only (owner or admin): see the table below. |
GET /billing/contact-packs | the whole response | The view behind the Contacts add-on screen: the limit, the subscription and the packs on sale. |
The team summary (contact_packs in GET /billing) has these fields:
| Field | What it tells |
|---|---|
plan_limit | The plan's contacts (null: unlimited). |
pack_contacts | The contacts the packs add now. |
effective_limit | The limit in force now: plan_limit + pack_contacts (null: unlimited). |
items | The packs the team has: pack_id, name, contacts, quantity, scheduled_quantity (the quantity after the renewal, or null with no reduction scheduled), contacts_total (contacts × quantity), max_quantity, amount (cents, per unit), currency and interval. |
scheduled | null, or the reduction waiting for the renewal: effective_at, pack_contacts and effective_limit (the limit after it). |
#Get the team's contact packs
GET/billing/contact-packs
What the Contacts add-on screen shows: the limit, the marketing subscription and the packs on sale in the subscription's currency and billing, plus the ones the team already has (even those taken off sale; whoever has one keeps the price they signed up for).
available is false when billing is not up or for a team billed through a partner. can_purchase says whether the team can add packs now, and reason, when it cannot: no_paid_subscription, subscription_not_active, billed_by_partner or billing_unavailable (null when it can). currency and interval are the subscription's. limit has plan, packs, effective and scheduled (null, or {"effective_at", "pack_contacts", "effective_limit"} when a reduction is scheduled). subscription is null without a paid marketing subscription. In packs, amount is the price of one pack, in cents, in the subscription's currency (null if the pack is not sold in it), prices lists the currencies, purchasable says whether the quantity can be increased, and quantity and scheduled_quantity are the team's.
Errors
- 403
forbiddenThe caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session).
GET /billing/contact-packs HTTP/1.1
Host: api.postal.so
{
"object": "contact_packs",
"available": true,
"can_purchase": true,
"reason": null,
"currency": "brl",
"interval": "month",
"contacts_used": 3120,
"limit": {
"plan": 5000,
"packs": 20000,
"effective": 25000,
"scheduled": null
},
"subscription": {
"id": "019a2e11-6c7d-7e8f-9a01-3b4c5d6e7f80",
"status": "active",
"currency": "brl",
"interval": "month",
"amount": 5900,
"current_period_end": "2026-11-20T12:00:00+00:00",
"cancel_at_period_end": false
},
"packs": [
{
"object": "contact_pack",
"id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
"name": "Pack A",
"contacts": 10000,
"max_quantity": 10,
"amount": 4900,
"currency": "brl",
"interval": "month",
"prices": [
{
"currency": "brl",
"amount": 4900
},
{
"currency": "usd",
"amount": 900
}
],
"purchasable": true,
"quantity": 2,
"scheduled_quantity": null
},
{
"object": "contact_pack",
"id": "019a2e10-4b5d-7e7f-9a81-2b3c4d5e6f70",
"name": "Pack B",
"contacts": 25000,
"max_quantity": 10,
"amount": 9900,
"currency": "brl",
"interval": "month",
"prices": [
{
"currency": "brl",
"amount": 9900
},
{
"currency": "usd",
"amount": 1900
}
],
"purchasable": true,
"quantity": 0,
"scheduled_quantity": null
}
]
}
#Quote a pack quantity
POST/billing/contact-packs/quote
Shows what would happen with this quantity of the pack, without changing anything: it is what the dashboard asks before the Confirm button. A change that cannot be made is not an error: the response is 200 with change.allowed: false and the reason in change.reason.
pack is the pack, as in the listing, but with quantity and scheduled_quantity zeroed: the team's quantities are in current (quantity, scheduled_quantity and today's limit). change.type is increase, decrease, keep (gives up a scheduled reduction) or same, and change.effective is now or next_period (with effective_at on a reduction). change.proration_amount is what is charged now on an increase, in cents, prorated for the rest of the period (null outside an increase or when the calculation is not available). change.reason (null when allowed) is one of: no_paid_subscription, subscription_not_active, billed_by_partner, billing_unavailable, pack_unavailable, currency_mismatch, interval_mismatch, max_quantity, same_quantity, subscription_ending or contacts_above_limit (with the blockers, in the format of downgrade_blocked).
after has limit_after_payment (the limit once the increase is paid), limit_after_renewal (the limit after the next renewal), pack_amount (this pack times the quantity, in cents) and recurring_total (what the subscription will cost per period: plan plus packs). Money values are null when they cannot be calculated.
Request body
pack_idstringrequiredThe pack ID, as inpacks[].idofGET /billing/contact-packs.quantityintegerrequiredThe total quantity the team wants to have of this pack, from 0 to 1000 (not how many to add). It can be higher (an increase), lower or0(a reduction).
Errors
- 403
forbiddenThe caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session). - 422
validation_errorUnknownpack_id, or aquantitythat is not an integer from 0 to 1000.
POST /billing/contact-packs/quote HTTP/1.1
Host: api.postal.so
Content-Type: application/json
{
"pack_id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
"quantity": 3
}
{
"object": "contact_pack_quote",
"pack": {
"object": "contact_pack",
"id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
"name": "Pack A",
"contacts": 10000,
"max_quantity": 10,
"amount": 4900,
"currency": "brl",
"interval": "month",
"prices": [
{
"currency": "brl",
"amount": 4900
},
{
"currency": "usd",
"amount": 900
}
],
"purchasable": true,
"quantity": 0,
"scheduled_quantity": null
},
"quantity": 3,
"currency": "brl",
"interval": "month",
"current": {
"quantity": 1,
"scheduled_quantity": null,
"limit": 15000
},
"change": {
"type": "increase",
"effective": "now",
"effective_at": null,
"proration_amount": 4000,
"allowed": true,
"reason": null,
"blockers": []
},
"after": {
"limit_after_payment": 35000,
"limit_after_renewal": 35000,
"pack_amount": 14700,
"recurring_total": 20600
}
}
{
"object": "contact_pack_quote",
"pack": {
"object": "contact_pack",
"id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
"name": "Pack A",
"contacts": 10000,
"max_quantity": 10,
"amount": 4900,
"currency": "brl",
"interval": "month",
"prices": [
{
"currency": "brl",
"amount": 4900
},
{
"currency": "usd",
"amount": 900
}
],
"purchasable": true,
"quantity": 0,
"scheduled_quantity": null
},
"quantity": 0,
"currency": "brl",
"interval": "month",
"current": {
"quantity": 3,
"scheduled_quantity": null,
"limit": 35000
},
"change": {
"type": "decrease",
"effective": "next_period",
"effective_at": "2026-11-20T12:00:00+00:00",
"proration_amount": null,
"allowed": false,
"reason": "contacts_above_limit",
"blockers": [
{
"key": "contacts",
"label": "Contacts",
"type": "quantity",
"used": 16000,
"limit": 15000,
"excess": 1000,
"action": "manage",
"manage_path": "/audience"
}
]
},
"after": {
"limit_after_payment": 35000,
"limit_after_renewal": 5000,
"pack_amount": 0,
"recurring_total": 5900
}
}
#Set the quantity of a pack
PUT/billing/contact-packs
Sets how many of this pack the team has (0 removes it). POST on the same path does the same. The quantity is the total, and the response says what was done in action:
| action | Status | What it means |
|---|---|---|
updated | 200 | The increase was charged and paid: the limit is already in force. The response carries the updated contact_packs. |
payment_pending | 202 | The payment was not confirmed (the card asks for confirmation, for example). invoice_url is the invoice link to pay. The limit does not change until the payment is confirmed: read GET /billing/contact-packs again later. |
scheduled | 200 | A reduction or removal was scheduled for the next renewal (effective_at), with no refund. The limit stays the same until then. |
reduction_canceled | 200 | You asked for the current quantity while a reduction was scheduled, and it was canceled. |
The route has a lower rate limit than the rest of the API, like the other routes that touch billing. Before confirming, use the quote to show the amount.
Request body
pack_idstringrequiredThe pack ID, as inpacks[].idofGET /billing/contact-packs.quantityintegerrequiredThe total quantity the team wants to have of this pack, from 0 to 1000 (not how many to add).0removes the pack at the next renewal.
Errors
- 403
forbiddenThe caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session). - 403
contact_packs_unavailableNo paid marketing plan, or the subscription is in a trial or has a pending payment. - 403
billed_by_partnerThe team is billed through a partner, who changes the packs. - 422
validation_errorA pack off sale, not sold in the subscription's currency or billing, a quantity abovemax_quantity, a quantity equal to the current one, or a subscription that ends at the end of the period. The message says which. - 422
payment_failedThe card was declined. Nothing changed. - 422
downgrade_blockedThe team has more contacts than the limit that would be left.blockerssays by how many contacts. - 502
stripe_unavailableThe payment service did not answer as expected. Try again in a moment; nothing changed. - 503
billing_unavailableBilling is not available right now.
PUT /billing/contact-packs HTTP/1.1
Host: api.postal.so
Content-Type: application/json
{
"pack_id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
"quantity": 2
}
{
"action": "updated",
"contact_packs": {
"object": "contact_packs",
"available": true,
"can_purchase": true,
"reason": null,
"currency": "brl",
"interval": "month",
"contacts_used": 3120,
"limit": {
"plan": 5000,
"packs": 20000,
"effective": 25000,
"scheduled": null
},
"subscription": {
"id": "019a2e11-6c7d-7e8f-9a01-3b4c5d6e7f80",
"status": "active",
"currency": "brl",
"interval": "month",
"amount": 5900,
"current_period_end": "2026-11-20T12:00:00+00:00",
"cancel_at_period_end": false
},
"packs": [
{
"object": "contact_pack",
"id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
"name": "Pack A",
"contacts": 10000,
"max_quantity": 10,
"amount": 4900,
"currency": "brl",
"interval": "month",
"prices": [
{
"currency": "brl",
"amount": 4900
},
{
"currency": "usd",
"amount": 900
}
],
"purchasable": true,
"quantity": 2,
"scheduled_quantity": null
},
{
"object": "contact_pack",
"id": "019a2e10-4b5d-7e7f-9a81-2b3c4d5e6f70",
"name": "Pack B",
"contacts": 25000,
"max_quantity": 10,
"amount": 9900,
"currency": "brl",
"interval": "month",
"prices": [
{
"currency": "brl",
"amount": 9900
},
{
"currency": "usd",
"amount": 1900
}
],
"purchasable": true,
"quantity": 0,
"scheduled_quantity": null
}
]
}
}
{
"action": "payment_pending",
"invoice_url": "https://invoice.example.com/i/inv_abc123",
"contact_packs": {
"object": "contact_packs",
"…": "…"
}
}
{
"action": "scheduled",
"effective_at": "2026-11-20T12:00:00+00:00",
"contact_packs": {
"object": "contact_packs",
"…": "…"
}
}
{
"statusCode": 422,
"name": "downgrade_blocked",
"message": "You have more contacts than the limit would be after reducing this pack. Remove contacts first, then reduce the pack.",
"blockers": [
{
"key": "contacts",
"label": "Contacts",
"type": "quantity",
"used": 16000,
"limit": 15000,
"excess": 1000,
"action": "manage",
"manage_path": "/audience"
}
]
}