Seat purchases, credit purchases, and subscription upgrades use the partner payment method (has_payment_method). Assigning credits to a member draws from the org pool and does not charge.
idempotency_key is optional, up to 64 characters. Repeating a request with the same key returns the original response and does not charge again. Reusing the key with a different body returns 400 (error: idempotency_conflict).
| Where | Path | What it does |
|---|---|---|
| Partner | POST /api/v1/seats/ |
Buys subscription seats (charge_type "subscription") |
| Partner | POST /api/v1/credits/ |
Buys unassigned on-demand credits into the org pool |
| Member | POST /api/v1/members/{member_id}/credits/{product_id}/add/ |
Assigns credits from the org pool. This call does not charge |
| Member | POST /api/v1/members/{member_id}/product/ |
Assigns an on-demand product from the org pool. This call does not charge |
| Member | POST /api/v1/members/{member_id}/change_subscription/{new_product_id}/ |
Upgrade from Core to Advanced |
POST /api/v1/seats/
Buys subscription seats and adds them to available right away. An on-demand product_id returns 400 (error: not_a_subscription).
| Product | Billing | What you get |
|---|---|---|
| Subscription | Added to the recurring subscription, prorated to the current period (monthly or annually). Seats in use and unused seats renew automatically |
quantity seats. An unused seat still bills |
Price comes from price_per_seat on the inventory row at the time of purchase. Purchases are final: seats cannot be refunded or released once bought, so size quantity to what you will assign. Inventory is GET /api/v1/seats/.
| Field | Required | Type | Description |
|---|---|---|---|
product_id |
Yes | integer | Subscription product_id from seat inventory |
quantity |
Yes | integer | Seats to buy. 1–1000 per request |
idempotency_key |
No | string | See above |
Core subscription seats:
POST /api/v1/seats/
Content-Type: application/json
Authorization: Bearer <api_key>
{
"product_id": 101,
"quantity": 25,
"idempotency_key": "order-2026-09-30-core-a1"
}
Advanced subscription seats:
{
"product_id": 103,
"quantity": 5
}
201 Created){
"product_id": 101,
"product_name": "Core",
"description": "Scan and removal subscription on the Core catalog.",
"charge_type": "subscription",
"billing_frequency": "monthly",
"quantity": 25,
"price_per_seat": 999,
"amount_charged": 12450,
"currency": "usd",
"invoice_url": "https://invoice.stripe.com/i/acct_123/inv_456",
"purchased_at": "2026-09-30T20:15:00.000000+00:00",
"seat_summary": {
"product_id": 101,
"total": 75,
"occupied": 12,
"available": 63
}
}
| Field | Type | Description |
|---|---|---|
product_id, product_name, description, charge_type, billing_frequency, price_per_seat |
— | Same values as GET /api/v1/products/ |
quantity |
integer | Seats added |
price_per_seat |
integer | Unit price in cents at purchase time |
amount_charged |
integer | Prorated charge for the current period, in cents |
currency |
string | ISO 4217 code |
invoice_url |
string | Stripe-hosted receipt for this charge |
purchased_at |
string | When the purchase completed (UTC, ISO 8601) |
seat_summary |
object | Updated total, occupied, and available for the product |
A repeated request with the same idempotency_key returns 200 OK with the original body.
| Status | Condition |
|---|---|
400 |
Unknown product_id (error: product_not_configured); on-demand product_id (error: not_a_subscription); quantity missing, not an integer, or out of range; idempotency_key reused with a different body (error: idempotency_conflict) |
401 |
Missing or invalid bearer token |
402 |
No payment method on file (error: payment_method_required), or the charge was declined (error: payment_failed; includes Stripe’s decline_code when available) |
403 |
Partner inactive, or the partner’s contract does not allow API purchases for this product (error: purchase_not_allowed) |
409 |
Purchase would exceed the contractual cap for the product (error: seat_cap_exceeded; includes total, max_total) |
No seats are added when the request fails.
Purchase history, invoices, and receipts are tracked in Stripe. Review them in the admin dashboard or follow invoice_url from the purchase response; the API does not list past purchases.
POST /api/v1/credits/
Buys on-demand credits and adds them to the org pool, unassigned. A subscription product_id returns 400 (error: not_on_demand). The pool is GET /api/v1/credits/. Assigning credits to a member draws from available and does not charge again.
| Product | Billing | What you get |
|---|---|---|
| On-demand product | Charged once, at price_per_seat × quantity |
quantity unassigned credits in the org pool |
| Field | Required | Type | Description |
|---|---|---|---|
product_id |
Yes | integer | On-demand product_id from GET /api/v1/products/ |
quantity |
Yes | integer | Credits to buy. 1–1000 per request |
idempotency_key |
No | string | See above |
POST /api/v1/credits/
Content-Type: application/json
Authorization: Bearer <api_key>
{
"product_id": 201,
"quantity": 25,
"idempotency_key": "credits-2026-10-12-a1"
}
201 Created){
"product_id": 201,
"product_name": "Core Scan",
"description": "On-demand scan of the Core catalog.",
"charge_type": "on_demand",
"billing_frequency": "one_time",
"quantity": 25,
"price_per_seat": 299,
"amount_charged": 7475,
"currency": "usd",
"invoice_url": "https://invoice.stripe.com/i/acct_123/inv_789",
"purchased_at": "2026-10-12T20:15:00.000000+00:00",
"credit_summary": {
"product_id": 201,
"total": 25,
"occupied": 0,
"available": 25
}
}
| Field | Type | Description |
|---|---|---|
product_id, product_name, description, charge_type, billing_frequency, price_per_seat |
— | Same values as GET /api/v1/products/ |
quantity |
integer | Credits added to the pool |
price_per_seat |
integer | Unit price in cents at purchase time |
amount_charged |
integer | Charge in cents |
currency |
string | ISO 4217 code |
invoice_url |
string | Stripe-hosted receipt for this charge |
purchased_at |
string | When the purchase completed (UTC, ISO 8601) |
credit_summary |
object | Updated total, occupied, and available for the product |
A repeated request with the same idempotency_key returns 200 OK with the original body.
| Status | Condition |
|---|---|
400 |
Unknown product_id (error: product_not_configured); subscription product_id (error: not_on_demand); quantity missing, not an integer, or out of range; idempotency_key reused with a different body (error: idempotency_conflict) |
401 |
Missing or invalid bearer token |
402 |
No payment method on file (error: payment_method_required), or the charge was declined (error: payment_failed; includes Stripe’s decline_code when available) |
403 |
Partner inactive, or the partner’s contract does not allow API purchases for this product (error: purchase_not_allowed) |
No credits are added when the request fails.
POST /api/v1/members/{member_id}/credits/{product_id}/add/
Assigns credits to an on-demand product already on the member. {product_id} is the on-demand catalog id. The credits are drawn from the org pool (GET /api/v1/credits/). This call does not charge. Balances on the member are in Scan credits.
POST /api/v1/members/70001/credits/201/add/
{
"quantity": 1,
"idempotency_key": "credits-70001-201-a1"
}
| Field | Required | Description |
|---|---|---|
quantity |
No | Credits to assign from the org pool. Default 1 |
idempotency_key |
No | See above. Repeating a request with the same key returns the original assignment and does not draw from the pool again |
200 OK){
"member_id": 70001,
"product_id": 201,
"credits_remaining": 2
}
| Status | Condition |
|---|---|
400 |
product_id is not an on-demand product on the member; quantity is not a positive integer; idempotency_key reused with a different body (error: idempotency_conflict) |
401 |
Missing or invalid API key |
402 |
Org pool available for that product is less than quantity, including when it is 0 (error: insufficient_credits; includes product_id and available). Buy with POST /api/v1/credits/ and retry |
403 |
Partner inactive |
404 |
Member or product not found |
{
"error": "insufficient_credits",
"product_id": 201,
"available": 0
}
No credits are assigned when the request fails.
POST /api/v1/members/{member_id}/product/
A subscription product_id on this call returns 400 (error: use_subscription_update). Change an existing subscription with Change subscription.
quantity is credits drawn from the org pool for that on-demand product. This call does not charge.
POST /api/v1/members/70001/product/
{
"product_id": 201,
"quantity": 1,
"idempotency_key": "product-70001-201-a1"
}
| Field | Required | Description |
|---|---|---|
product_id |
Yes | An on-demand product from GET /api/v1/products/ |
quantity |
No | Credits to assign from the org pool. Default 1 |
idempotency_key |
No | See above. Repeating a request with the same key returns the original assignment and does not draw from the pool again |
200 OK)Returns the account object plus member_id and exposures_activated. The product is assigned on that member. When is_primary is true, the account stores it in products, and in primary_subscription_product_id when the product is a subscription. exposures_activated is how many exposures moved from BLOCKED to ACTIVE when the member only had on-demand products and a subscription is added. The count is 0 when the added product is on-demand.
{
"account_id": 12000,
"member_id": 70001,
"status": "ACTIVE",
"primary_subscription_product_id": null,
"products": [
{
"product_id": 201,
"product_name": "Core Scan",
"description": "On-demand scan of the Core catalog.",
"charge_type": "on_demand",
"billing_frequency": "one_time",
"price_per_seat": 299,
"credits_remaining": 1
}
],
"exposures_activated": 0,
"members": [
{
"member_id": 70001,
"primary_name": "Scan User",
"primary_email": "scan@company.com",
"is_primary": true,
"profile_threat_score": null
}
]
}
| Status | Condition |
|---|---|
400 |
product_id missing or unknown; product_id is a subscription (error: use_subscription_update); quantity not a positive integer; idempotency_key reused with a different body (error: idempotency_conflict); account is TERMINATED |
401 |
Missing or invalid API key |
402 |
Org pool available for that product is less than quantity, including when it is 0 (error: insufficient_credits; includes product_id and available). Buy with POST /api/v1/credits/ and retry |
403 |
Partner inactive |
404 |
Member not found |
POST /api/v1/members/{member_id}/change_subscription/{new_product_id}/
The same seat moves to {new_product_id}. No seat is added and none is returned. An upgrade from Core to Advanced is charged on the partner payment method at the time of the change. The subscription resource is in Change subscription.
POST /api/v1/members/67890/change_subscription/103/
{
"idempotency_key": "upgrade-67890-103-a1"
}
| Field | Required | Description |
|---|---|---|
idempotency_key |
No | See above |
| Status | Condition |
|---|---|
400 |
{new_product_id} is not a subscription, is the product already on the member, or the member has no subscription (error: invalid_subscription_change); idempotency_key reused with a different body (error: idempotency_conflict); account is TERMINATED |
401 |
Missing or invalid API key |
402 |
No partner payment method on file (error: payment_method_required), or the charge was declined (error: payment_failed) |
403 |
Partner inactive |
404 |
Member or product not found |