Engineering

How to change subscription seat counts without double-charging a customer

How to change subscription seat counts without double-charging a customer: retry the exact seat total and read the 409 error naming the unresolved change.

The short answer

Change a mailbox licence's seat count by sending the new total, not the change, and retry with that exact same total if a response is ever lost. ResellerHQ's PATCH /v1/licences/{id} route works this way, and when an earlier seat change may have taken effect at Google or Microsoft but its confirmation never came back, it answers 409 licence_charge_in_doubt and states the exact seat count to retry rather than silently applying a second one. Adding seats charges a full month for each one added, at the price the licence was bought at; removing seats stops billing for them at once and is not refunded.

The short answer to how to change subscription seat counts without double-charging a customer: send the new total number of seats, not the change, and if you ever lose the response, retry with that exact same total. ResellerHQ's route for this, PATCH /v1/licences/{id}, works that way on purpose: the body takes seats as "the new total, not the change", and if an earlier attempt may have already landed at the vendor, the next request is matched against that specific total before anything is charged again.

This post covers the one case the published idempotency keys and webhooks article does not: a subscription seat count, which is changed, not just bought once. If you have not read that article, read it first for how the Idempotency-Key header and the IETF draft behind it work in general; this post assumes that background and goes straight to what is different about seats.

Why a seat change is harder to retry safely than a one-off purchase

Registering a domain or renting a number is a single event: either it happened or it did not, and an Idempotency-Key on the request lets a retry get back whatever the first attempt decided. A seat change is different because it mutates something that already exists. The request does not say "do a thing"; it says "make this licence have 8 seats", starting from whatever it had before.

That distinction matters the moment a response is lost. Suppose a licence has 5 seats and a client sends a request to change it to 8. The connection drops before the answer comes back. From the client's side, three things might be true: the request never reached the server, it reached the server and was refused outright (insufficient funds, for example), or it reached the server, the server told Google or Microsoft to add 3 seats, the vendor accepted that, and only the confirmation back to the client was lost. Stripe describes the general version of this case plainly: "if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice" (Stripe: Idempotent requests), provided the retry is built correctly.

That last proviso is where seat changes are riskier than a one-shot purchase. If a client treats "the first request might have failed" as "try adding 3 seats again", it can end up requesting 11 seats instead of 8, because it reasoned in deltas instead of totals. The added seats charge a full month each, so that mistake is also a billing mistake: a customer told you added 3 seats gets charged for 6, for a request that only reached the vendor once.

How to change subscription seat counts without double-charging a customer

ResellerHQ closes that gap in two layers. The first is the ordinary Idempotency-Key header, which protects an exact duplicate: the same key with the same body returns the first outcome instead of running again, no matter which route it is sent to. The second, specific to seat changes, is a check inside the licence itself that does not care what idempotency key you send.

When a seat change's call to the vendor comes back ambiguous rather than a clear yes or no, ResellerHQ records the pending change against the licence (seatChargeInDoubt: the seat count, the amount it would charge and the ledger key already used to hold the money) and answers:

409 licence_charge_in_doubt
"An earlier seat change on this licence may have taken effect upstream and is
unresolved; retry that exact seat count to resolve it"

The response, in practice, names the exact pending count: a licence stuck mid-change to 8 seats answers with a message like "A previous change to 8 seats may have taken effect upstream and is unresolved; retry that exact change (8 seats) to resolve it." A client that sends that same request again, {"seats": 8}, is recognised as the resolving retry regardless of which Idempotency-Key header it carries, because the match is on the seat count and the charge amount stored against the licence, not on the header. ResellerHQ also reuses the original ledger hold for that retry rather than opening a new one, so a resolving retry cannot reserve the money twice even if the first attempt did reach the vendor.

A minimal retry loop for this route looks like the one in the idempotency post, with one change: the retry must resend the same target seat count, not a recomputed delta.

async function setSeats(licenceId: string, seats: number, key = crypto.randomUUID()) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`https://resellerhq.com/api/v1/licences/${licenceId}`, {
      method: 'PATCH',
      headers: {
        authorization: `Bearer ${process.env.RHQ_API_KEY}`,
        'content-type': 'application/json',
        'idempotency-key': key,
      },
      body: JSON.stringify({ seats }), // always the target total, never "+3"
    }).catch(() => null);

    // 409 here can mean "another change is running" (licence_busy) or "an earlier
    // change may already have landed" (licence_charge_in_doubt). Either way, the fix
    // is the same: wait, then send this exact `seats` value again.
    const retry = !res || res.status === 409 || res.status === 429 || res.status >= 500;
    if (!retry) return res;
    await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
  }
  throw new Error(`seat change to ${seats} on ${licenceId} unresolved, check before retrying`);
}

What happens if you retry with a different seat count

A licence with a charge in doubt stays locked to that one pending change. If you send a different seat count, or the same count at a different price than the one recorded, the licence_charge_in_doubt check fails and the same error comes back, still naming the seat count it is waiting for. You cannot change your mind about the target while a change is unresolved: first resolve the one you started by retrying it exactly, then send a separate request for whatever count you actually want.

This is distinct from licence_busy, the other 409 this route can answer. licence_busy means a different change (or the automatic monthly renewal) is running on the same licence right now; it clears on its own once that finishes. licence_charge_in_doubt means a specific earlier attempt is unresolved and waiting for you, specifically, to confirm it by retrying.

What this looks like in ResellerHQ today

Buying seats and changing them are two separate calls. Creating a licence takes a plan, a domain and a customer, and charges the first month immediately:

curl -X POST https://resellerhq.com/api/v1/licences \
  -H "Authorization: Bearer $RHQ_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"plan":"google-workspace-starter","domain":"northwind.io","seats":5,"customerId":"cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA"}'

Changing the seat count is a PATCH to that licence, with the new total:

curl -X PATCH https://resellerhq.com/api/v1/licences/LICENCE_ID \
  -H "Authorization: Bearer $RHQ_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"seats":8}'

Per the mailbox licences guide and the route's own description, the two directions behave differently and neither is negotiable: adding seats charges a full month for each added seat, at the price the licence was bought at; removing seats takes effect at once and is not refunded. Only the adding direction carries a charge, which is why only the adding direction can end up licence_charge_in_doubt.

One honest limit: as of October 2026, mailbox licences run in ResellerHQ's sandbox only, and the sandbox's licence provider does nothing on a seat change and always succeeds, with no live call to Google or Microsoft to leave ambiguous. licence_charge_in_doubt is a real, documented error on a real route, but it needs a live vendor connection to actually occur, which the sandbox does not have. Build your retry logic to handle it anyway, since the error is part of the published contract and the API reference for updating a licence lists it as a possible response.

Does this apply to both Google Workspace and Microsoft 365?

Yes, the same way. The seat-change route and its errors do not branch by vendor; a licence carries a vendor of google or microsoft, but the request body, the pricing rule and the licence_charge_in_doubt check are identical either way. Where the two vendors genuinely differ is covered elsewhere: the trade-offs of reselling each are in Google Workspace vs Microsoft 365: which is better to resell?, and Microsoft's own commercial rules for changing a live CSP subscription (including the seven-calendar-day window in which a licence count can be reduced at all) are in our Microsoft 365 CSP reseller guide. That seven-day rule is Microsoft's policy about whether a reduction is allowed; licence_charge_in_doubt is about what to do when a request you already sent gets no clear answer, which is a different problem and can come up on either vendor.

A short checklist for a seat-change integration

  • Does the request body carry the new total, or a delta? A total is safer to retry, because sending the same total twice lands on the same state instead of compounding.
  • Is there a distinct signal for "an earlier change here may already have taken effect", separate from "someone else is mid-change on this object"? ResellerHQ answers the first with licence_charge_in_doubt and the second with licence_busy.
  • Does resolving that signal require reusing the original idempotency key, or does the provider track it against the object itself? ResellerHQ tracks it against the licence, so a new key on the retry is fine as long as the seat count matches.
  • Is only one direction of the change billed? Know which one, because that is the direction that can be left in doubt.

For the fuller provider-evaluation checklist, covering idempotency keys and webhooks together, see the checklist in the idempotency post.

Questions

  • It means an earlier request to change this licence's seat count may have reached Google or Microsoft and been applied there, but ResellerHQ never received a usable confirmation, so it cannot tell you whether it worked. The licence is held in that state until a retry with the exact same seat count resolves it, which is what the error message tells you to send.
  • Send the PATCH request again with the same seats value that caused the uncertainty. ResellerHQ recognises that value as the resolving retry, tries the upstream change again, and clears the hold on success. Sending a different seat count while the licence is in this state is refused with the same error, naming the seat count you need to retry first.
  • No. The check that resolves a licence stuck in licence_charge_in_doubt compares the seat count and the charge amount recorded against the licence, not the Idempotency-Key header on the request. You can reuse the original key or send a new one; what matters is that the seats value matches the pending change.
  • No, because removing seats is not billed. Per ResellerHQ's mailbox licences documentation, added seats are charged a full month each at the licence's bought price, and that charge is what can be left in doubt; removed seats take effect at once and are not refunded, so there is no charge to retry or leave unresolved.
  • No, and they should not be confused. Microsoft's New Commerce Experience lets a partner reduce licence counts only within the first seven calendar days of a subscription term, which is a commercial policy about when a change is allowed at all, covered in our Microsoft 365 CSP reseller guide. licence_charge_in_doubt is about what a reseller's own client should do when a request it already sent gets no clear answer back, which can happen at any point in a seat change.
  • Not yet, because the sandbox licence provider does nothing on a seat change and always succeeds immediately - the new seat total is recorded on the licence by ResellerHQ itself, and there is no live call to Google or Microsoft that could leave an outcome unresolved. The route, the error and its exact wording are real and documented, but a sandbox integration will not see this error fire until the API is tested against a live vendor.

Sources

  1. Stripe: Idempotent requests docs.stripe.com

Keep reading

More on selling under your own name

Start selling under your own brand

  1. Sign up with an email, no call needed1 minute
  2. Get test keys and $5,000 of pretend moneyInstantly
  3. Take order #0001 under your own nameToday