Sign in Create account
API reference

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

Public, no key

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.

GET /plans

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);
200Response (abbreviated, with illustrative values)
{
  "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: with validity_days: 0, expires_at is null and 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: 403 plan_limit_reached. A team billed through a partner: 403 billed_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

RouteFieldWhat it returns
GET /usageemails.creditsThe 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 /billingemails.monthly, email_creditsThe team summary, with the dashboard session only (owner or admin): see the table below.
GET /billing/email-creditsthe whole responseThe 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:

FieldWhat it tells
emails.monthlyThe plan's monthly quota: used, limit (null: unlimited) and resets_at. The credits are never added to it.
email_creditsThe 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

Dashboard session (owner or admin)

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

  • 403invalid_access
    No team is selected in the session.
Request
GET /billing/email-credits HTTP/1.1
Host: api.postal.so
200Response (illustrative values)
{
  "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

Dashboard session (owner or admin)

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

Errors

  • 403forbidden
    The caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session).
  • 403billed_by_partner
    The team is billed through a partner, who handles the add-on.
  • 403plan_limit_reached
    The plan has no monthly sending limit, so there is nothing to extend.
  • 422validation_error
    A missing pack_id, one that is not a UUID or does not exist, or a pack that is no longer for sale (the message says which).
  • 503billing_unavailable
    Billing is not available right now.
Request
POST /billing/email-credits/checkout HTTP/1.1
Host: api.postal.so
Content-Type: application/json

{
  "pack_id": "019a3f20-5b6c-7d7e-8f90-1a2b3c4d5e6f"
}
200Response (illustrative value)
{
  "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 200 with action: "updated"; if the payment needs confirmation (card authentication, for example), it is 202 with action: "payment_pending" and the invoice_url of the invoice to pay, and the limit does not change until it is paid. Declined payment: 422 payment_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 with 422 downgrade_blocked (and the blockers list) 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: 403 contact_packs_unavailable. A team billed through a partner: 403 billed_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

RouteFieldWhat it returns
GET /planscontact_packsThe 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 /usagecontactslimit 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 /billingcontact_packsThe team summary, with the dashboard session only (owner or admin): see the table below.
GET /billing/contact-packsthe whole responseThe 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:

FieldWhat it tells
plan_limitThe plan's contacts (null: unlimited).
pack_contactsThe contacts the packs add now.
effective_limitThe limit in force now: plan_limit + pack_contacts (null: unlimited).
itemsThe 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.
schedulednull, 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

Dashboard session (owner or admin)

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

  • 403forbidden
    The caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session).
Request
GET /billing/contact-packs HTTP/1.1
Host: api.postal.so
200Response (illustrative values)
{
  "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

Dashboard session (owner or admin)

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_idstringrequired
    The pack ID, as in packs[].id of GET /billing/contact-packs.
  • quantityintegerrequired
    The 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 or 0 (a reduction).

Errors

  • 403forbidden
    The caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session).
  • 422validation_error
    Unknown pack_id, or a quantity that is not an integer from 0 to 1000.
Request
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
}
200Response (an increase from 1 to 3; illustrative values)
{
  "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
  }
}
200A refused reduction: the team has more contacts than the limit that would be left
{
  "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

Dashboard session (owner or admin)

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:

actionStatusWhat it means
updated200The increase was charged and paid: the limit is already in force. The response carries the updated contact_packs.
payment_pending202The 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.
scheduled200A reduction or removal was scheduled for the next renewal (effective_at), with no refund. The limit stays the same until then.
reduction_canceled200You 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_idstringrequired
    The pack ID, as in packs[].id of GET /billing/contact-packs.
  • quantityintegerrequired
    The total quantity the team wants to have of this pack, from 0 to 1000 (not how many to add). 0 removes the pack at the next renewal.

Errors

  • 403forbidden
    The caller is not the team owner or an admin, or used an API key (these routes only accept the dashboard session).
  • 403contact_packs_unavailable
    No paid marketing plan, or the subscription is in a trial or has a pending payment.
  • 403billed_by_partner
    The team is billed through a partner, who changes the packs.
  • 422validation_error
    A pack off sale, not sold in the subscription's currency or billing, a quantity above max_quantity, a quantity equal to the current one, or a subscription that ends at the end of the period. The message says which.
  • 422payment_failed
    The card was declined. Nothing changed.
  • 422downgrade_blocked
    The team has more contacts than the limit that would be left. blockers says by how many contacts.
  • 502stripe_unavailable
    The payment service did not answer as expected. Try again in a moment; nothing changed.
  • 503billing_unavailable
    Billing is not available right now.
Request
PUT /billing/contact-packs HTTP/1.1
Host: api.postal.so
Content-Type: application/json

{
  "pack_id": "019a2e10-4b5c-7d6e-8f70-1a2b3c4d5e6f",
  "quantity": 2
}
200Response (a paid increase; illustrative values)
{
  "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
      }
    ]
  }
}
202The payment needs to be confirmed: the limit has not changed
{
  "action": "payment_pending",
  "invoice_url": "https://invoice.example.com/i/inv_abc123",
  "contact_packs": {
    "object": "contact_packs",
    "…": "…"
  }
}
200A reduction for the next renewal
{
  "action": "scheduled",
  "effective_at": "2026-11-20T12:00:00+00:00",
  "contact_packs": {
    "object": "contact_packs",
    "…": "…"
  }
}
422Reduction refused
{
  "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"
    }
  ]
}