8. API keys
Create keys on the panel's Developer API page (/api). The plaintext is
shown once — store it. Each key has an optional per-day cap (max_per_day;
0 = server default).
| Action | Where |
|---|---|
| Create / list / pause / delete | panel /api page (session-authenticated) |
| Verify a key + read balance | GET https://mp.dvocorp.com/api/v1/me/ping with the key |
- Pausing a key is reversible — the same plaintext works again after you re-enable it. Deleting is permanent; pause instead if you might need it.
- A key revoked by an administrator can't be re-enabled or deleted by you.
- You cannot mint a key with a key — key management needs a panel session.
Signing in to the panel (to get that session)
Two doors, one account: email + password, or Continue with Google. A Google sign-in creates the ordinary account (same starter bonuses, same plans, same keys); signing in with Google using an address that already has an account takes you into that account rather than making a second one.
| Action | Endpoint |
|---|---|
| Password sign-in | POST https://mp.dvocorp.com/api/v1/auth/login {email, password, captcha_token?} |
| Start a Google sign-in | POST https://mp.dvocorp.com/api/v1/auth/google/start {next, mode} → {authorization_url} |
Finish it (browser returns with ?code=&state=) |
POST https://mp.dvocorp.com/api/v1/auth/google/callback {code, state} → {access_token, created} |
| Set / change your own password | POST https://mp.dvocorp.com/api/v1/auth/password {current_password?, new_password} |
| Attach / detach Google | POST / DELETE https://mp.dvocorp.com/api/v1/auth/google/link |
| Register with an email | POST https://mp.dvocorp.com/api/v1/auth/register {email, password, full_name?, captcha_token?} |
| Is this address usable? | POST https://mp.dvocorp.com/api/v1/auth/email/check {email} → {acceptable, verdict} |
| Send / resend the confirmation code | POST https://mp.dvocorp.com/api/v1/auth/email/send-code |
| Confirm with the code | POST https://mp.dvocorp.com/api/v1/auth/email/verify {code} → {email_verified, granted_credits} |
Confirming your email
Registration works with any mailbox, not just Gmail. The account is created and fully usable immediately — studio, API keys, uploads, everything. What waits for confirmation is the welcome bonuses.
Nothing is emailed by /auth/register. The 6-digit code is sent when you ask
for it (/auth/email/send-code, which is what the banner's Confirm button
calls) — a message sent during signup is one that ages out unread. Entering the
code adds the welcome bonuses to your balance and subscribes you to job
notifications and product news. /auth/email/status reports code_pending (one is already in the
inbox, so don't send another) and resend_available_in (seconds until the next
send is allowed).
Signing in with Google skips this — Google already verified the address, so the bonuses are granted right away.
Because open signup is the one endpoint that costs us money per request, it is
guarded: a Cloudflare Turnstile captcha (when configured — sign-in carries the
same check, since that is where leaked-password lists get tried), a per-IP
rate limit,
an address check (shape + does the domain accept mail + no disposable inboxes),
and hard limits on how often a code can be sent or guessed. verdict from
/auth/email/check is one of ok | invalid | no_domain | disposable | unknown
(unknown = our DNS could not answer, and the address is still accepted).
The captcha's public site key, whether confirmation is available and what it
pays are all in GET https://mp.dvocorp.com/api/v1/public/config
(turnstile_site_key, email_verification_enabled, verify_bonus_credits).
- These are browser flows — the SPA drives them. Nothing here accepts (or needs) an API key, and API keys are unaffected by how you signed in.
current_passwordis required only if you already have one. An account created through Google sets its first password without it — which is how you keep access if Google is ever unavailable or you move to another Google account. An administrator can also issue a password from the panel.- Detaching Google is refused while it is your only way in.
- The button appears only when the deployment is configured for it
(
GET https://mp.dvocorp.com/api/v1/public/config→google_auth_enabled).
Bonus history lives at
GET https://mp.dvocorp.com/api/v1/billing/ledger?limit=&offset=&entry_type= (panel session;
entry_type: purchase | debit | refund | signup_bonus | adjust | subscription | expire) — the same data the /ledger page shows.
Your orders are a different list, at
GET https://mp.dvocorp.com/api/v1/billing/payments?limit=&offset= (panel session), newest
first. The ledger records every movement of your balance; this records what you
paid for. They are not interchangeable: a plan term is one order followed by
a monthly grant, so reading receipts off the ledger would print twelve invoices
for one charge. Each row carries order_id (the number to quote to support),
kind (package | subscription), item_name, credits (on a plan order, the
whole term, not the monthly allowance), months, extends_term, amount_cents
/ currency, actually_paid_cents, status and credited_at. Orders that
never completed are returned too — if you paid and saw nothing, the order_id
is here.
Plans & bonuses
Everything below needs a panel session (the access_token from
/auth/login), not an API key — buying is a browser flow. To read your balance
with a key, use GET https://mp.dvocorp.com/api/v1/me/ping instead.
Naming. The panel calls the unit bonuses. Nothing is renamed in the API: it is still
creditson a balance,credits_per_callon an operation,period_creditson a plan, and the 402 body still readsnot enough credits. Same numbers, same field names — only the word a human reads changed.
# 0. the public price list — no account, no key, never cached
curl https://mp.dvocorp.com/api/v1/public/pricing
# -> {"packages":[{"id":1,"name":"Starter","credits":500,"price_cents":500,
# "charge_cents":500,"reference_cents":null,"badge":null,
# "is_popular":false,"currency":"USD"}],"plans":[…],"tax_included":false}
The response is the price list; the line above is only its shape. Packs and plans are edited in the admin panel, so any figure written into this document is stale the moment someone changes one — read the amounts from the call, never from here. Prices are pre-tax: sales tax or VAT is added at checkout by the reseller, based on where the buyer is. The endpoint reads the same rows the checkout charges from, so the quoted figure and the charged figure cannot drift.
# 1. what you can buy — prices are already personalised for YOUR account
curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/plans
# -> [{"id":2,"slug":"pro","name":"Pro","price_cents":2900,"currency":"USD",
# "period_credits":6000,"period_days":30,"priority":5,
# "is_locked_price":false,"list_price_cents":2900,
# "terms":[{"months":1,"credits_per_month":6000,"credits_total":6000,
# "monthly_price_cents":2900,"undiscounted_cents":2900,
# "charge_cents":2900,"discount_pct":0,"currency":"USD"}]}]
# Shape, not a quote — the amounts come from the call, not from this page.
price_cents and period_credits are per month. terms is the list of
term lengths you may buy in one payment; it always has at least the one-month
entry, and charge_cents on the entry you pick is exactly what you will be
charged. Do not compute a multi-month price yourself — undiscounted_cents is
the struck-through reference, charge_cents is the bill, and discount_pct is
derived from the two. terms also appears on the anonymous
GET /public/pricing plans, at list price.
Promotions: what the extra fields on a pack or a plan mean
Both GET /public/pricing and GET /billing/plans carry these. All of them are
resolved by the server; none of them is something you compute.
| Field | On | Means |
|---|---|---|
charge_cents |
pack, plan | The amount that will actually be taken. On a plan this is the monthly figure with the tier's promo applied; price_cents beside it is the undiscounted monthly rate |
reference_cents |
pack, plan | The struck-through "before" price, or null |
badge |
pack, plan | A short key, not a sentence — recommended, new, limited, or absent. Render your own wording for it |
is_popular |
pack, plan | This row is the most bought over the trailing window. Computed from paid orders; nobody can set it |
promo_ends_at |
pack, plan | When the discount stops, if it has an end. A date, ISO-8601 |
reference_cents has three states and they are not the same thing.
- A number — the lowest price this product was genuinely on sale at over the last 30 days, and never above your own undiscounted rate. Strike it through, and derive any percentage from it.
null— show no reduction at all: no strike-through, no percentage, no "on sale" wording. Either the product has no price history to anchor to, or the current price is not actually below what it has recently sold at. Do not fall back toprice_centsin this case.- Absent — the row does not carry an anchor (a term total, a bundle half, a payment record). Those have their own reference, described below.
Two reasons that matters if you are rendering our prices anywhere: the percentage has to be calculated from the 30-day reference rather than from the last regular price (CJEU C-330/23, Aldi Süd, 26 Sep 2024), and a "reduced" price that equals or exceeds the prior one may not be presented as a reduction at all (same judgment, para. 27).
A multi-month term uses a different reference and the two must not be mixed on
one line. A term is struck through against undiscounted_cents — your own
monthly rate × the months — because it is a tied offer ("this rate when you buy
three months"), not a price that fell over time. reference_cents belongs to the
monthly price only.
Discounts compose by multiplication. A 20% plan promo and a 25% twelve-month
term is 40% off, not 45%. You never have to do that arithmetic —
charge_cents and discount_pct are both already computed — but if you are
reconciling our numbers against your own, that is the rule.
# 2. how you can pay — one entry means there is no choice to make
curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/providers
# -> [{"name":"liqpay","label":"LiqPay","kind":"card","is_default":true},
# {"name":"coinbase","label":"Coinbase Commerce","kind":"crypto","is_default":false}]
# 3. start a plan purchase -> open `redirect_url` in a browser to pay
# "provider" is optional: omit it for the default rail above
# "months" is optional and defaults to 1 — one of the `months` values in
# `terms` above; anything else is refused with 400
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"plan_id":2,"months":6,"provider":"coinbase",
"accept_documents":true,"accept_immediate":true,"consent_locale":"en"}' \
https://mp.dvocorp.com/api/v1/billing/subscribe
# -> {"order_id":"ord_...","redirect_url":"https://commerce.coinbase.com/charges/...",
# "status":"waiting","provider":"coinbase","balance":100}
One endpoint, two outcomes, and the server picks. Post it with no term
running and you open a new one. Post it for the same plan while a term is
running and you extend that term — the months are added to the end and
nothing about the month you are already in changes. Post it for a different
plan while a term is running and you get 409: there is no proration
here, and refusing is the only answer that cannot destroy time you already paid
for. Contact support to change tier; they can refund and re-sell.
# 4. or buy a one-off bonus pack (no monthly reset on these)
# on this deployment a pack is an ADD-ON: you need a running plan term to
# buy one — see below, and check `purchasable` before you post
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"package_id":1,"provider":"liqpay",
"accept_documents":true,"accept_immediate":true,"consent_locale":"en"}' \
https://mp.dvocorp.com/api/v1/billing/checkout
# 4b. or buy BOTH IN ONE ORDER — a term and a pack, one charge, the pack
# discounted for being bought together. Quote it first, then post the same
# `package_id` to /subscribe; there is no second checkout and no second
# payment.
curl -H "Authorization: Bearer $JWT" \
"https://mp.dvocorp.com/api/v1/billing/bundle-quote?plan_id=2&package_id=1&months=6"
# -> {"plan_id":2,"package_id":1,"months":6,
# "term":{"months":6,"credits_per_month":6000,"credits_total":36000,
# "monthly_price_cents":2900,"undiscounted_cents":17400,
# "charge_cents":14700,"discount_pct":15,"currency":"USD"},
# "pack_credits":500,"pack_list_cents":500,"pack_charge_cents":425,
# "pack_discount_pct":15,"total_cents":15125,"currency":"USD"}
# Shape, not a quote — the amounts come from the call, not from this page.
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"plan_id":2,"months":6,"package_id":1,
"accept_documents":true,"accept_immediate":true,"consent_locale":"en"}' \
https://mp.dvocorp.com/api/v1/billing/subscribe
# -> {"order_id":"ord_...","status":"waiting","provider":"liqpay","balance":100}
One order, one charge, two line items. total_cents is what the rail takes
and it is the only figure you should show a customer — do not add
term.charge_cents and pack_charge_cents yourself, and do not apply a
percentage to anything. The quote comes from the same function the checkout
charges from, so the number you were quoted and the number you are debited
cannot differ.
The discount applies to the PACK only. The term keeps whatever its own
length already earns it (term.discount_pct, from the term ladder) and the
bundle percentage comes off the pack (pack_discount_pct). The two compose;
neither is applied twice. pack_list_cents is what the same pack costs bought
on its own — a real price you could be charged — which is why it is the honest
thing to strike through. It is a conditional offer: post
POST /billing/checkout for that pack alone and you are charged
pack_list_cents, so any UI showing the lower figure has to say what the
condition is.
A bundle is not refused by the pack rule. The rule below asks for a running term; in a bundle the term is in the same order, which is exactly the case it has to allow — so an account with no subscription at all can buy one. A pack on its own is still refused.
Settlement delivers both, exactly once. One payment opens (or extends) the
term and grants the pack in the same transaction. The pack's bonuses land in
full and carry expires_at: null — they never expire, and buying them beside a
term changed the price and nothing else. The term's allowance still arrives one
month at a time and each month's remainder still lapses when the next lands;
a combined balance figure must never blur the two. GET /billing/payments
returns the pack half as bundle_item_name / bundle_credits /
bundle_amount_cents / bundle_discount_pct, separate from credits and
amount_cents, for the same reason.
GET /public/config → billing.bundle_pack_discount_pct is the rule (the
percentage, published for anyone); every pack row on GET /billing/packages and
GET /public/pricing also carries bundle_charge_cents, so a price list can
show the bundled figure before a plan is chosen without computing anything. 0
means the owner has switched the incentive off — a pack can still be added to a
term, at its ordinary price.
TWO CONSENTS, AND BOTH ARE REQUIRED. Every purchase call carries two
booleans, and a checkout missing either is refused with 400 naming the one
that is missing. They are separate fields because one tick cannot lawfully
carry both: express consent to immediate performance takes a positive act of
its own, and accepting the general terms and conditions does not supply it
(European Commission Notice 2021/C 525/01, §5.7 on the Consumer Rights
Directive). The single accept_terms field the old contract used is gone,
not aliased — a synonym that satisfied both would rebuild the defect silently.
| field | what it means |
|---|---|
accept_documents |
the buyer accepts the Terms of Service and the Refund Policy |
accept_immediate |
the buyer expressly asks you to begin providing the service at once, and acknowledges that bonuses already spent on completed jobs cannot be handed back |
consent_locale |
optional; which translation of those two sentences was on screen (en, uk, ru). Stored on the order so the confirmation can quote them back in the language they were read in. Anything else is stored as "unrecorded" |
terms_version |
optional and ignored. The server stamps its own published version — a value you supply identifies nothing |
Send true for either one only when a person actually ticked a box that
said so, unticked, in front of them. accept_immediate does not waive the
right of withdrawal and must never be presented as if it did: buying a
unit-denominated balance is not the supply of digital content, so unspent
bonuses stay refundable.
400 {"detail":"tick every box before paying — still missing: accept_immediate (your request that we begin providing the service immediately)"}
You do not have to build the receipt. When an order is delivered — the moment the bonuses land — we email the buyer a confirmation of the concluded contract: the order id and date, the line items (both halves of a bundle, and for a plan the term length and the fact that the allowance arrives monthly), the amount and currency, the two consent sentences they ticked quoted verbatim with the documents version, their right of withdrawal and how to exercise it, and links to the Terms, the Refund Policy and their invoices. It is sent once per order, in the language recorded on the order (English otherwise), and it is not sent again on a repeated provider callback.
A pack may need a plan. When the operator has switched packs to add-ons,
POST /billing/checkout refuses an account with no active subscription:
409 {"detail":{"code":"subscription_required",
"message":"a one-off pack needs an active subscription — start a monthly plan first"}}
The detail is an object, not a string, so code is the field to branch on —
it is what separates this from a failed payment. Nothing is opened by the
refusal: there is no order to poll and nothing to cancel. Buy a term with
POST /billing/subscribe (plans are never gated — that is the way in) and the
packs open up.
Two ways to know before you post, and both come from the server:
GET /billing/packages— every row carriespurchasable, the answer for your account with the rule, the rails and the billing audience already resolved. The rows are still listed when it isfalse; only the checkout is closed.GET /public/config→billing.packs_require_subscription— the rule, published for anyone (no auth). It says what the deployment requires, not whether you personally qualify.
This gates buying, never spending. Packs you have already bought keep
expires_at: null and stay spendable for ever — after a term lapses, after it
is cancelled, and whatever this setting is later changed to. Only the plan's own
monthly allowance is swept at the end of a month; see the table below.
# 5. your balance, and how much of it renews
curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/balance
# -> {"balance":6100,"expiring":6000,"expires_at":"2026-09-03T10:00:00Z"}
# 6. the term you hold — or null
curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/subscription
# -> {"plan_name":"Pro","period_start":"2026-08-29T10:00:00Z",
# "period_end":"2027-02-25T10:00:00Z","credits_per_month":6000,
# "months_total":6,"months_granted":1,
# "next_grant_at":"2026-09-28T10:00:00Z","can_extend":true,
# "auto_renew":false,"renewal_status":"off","renews_at":null,
# "renewal_amount_cents":0,"renewal_months":0,
# "cancel_at_period_end":false,"grace_until":null,"can_cancel":false}
Two dates, and they mean different things. next_grant_at is when your next
monthly allowance lands (and when the current one is void); period_end is when
access ends. On a multi-month term they are far apart — a countdown that shows
one of them is wrong about the other. Both are server timestamps; compute from
them, not from a client clock plus a duration. next_grant_at is null when
every allowance has been delivered and the term is simply running out.
credits_per_period is the old name for credits_per_month and carries the
same number; it will be dropped a release from now.
How bonuses behave. You have one balance, and up to four kinds of bonus can be sitting in it:
| Bonus | Ledger entry_type |
Expires |
|---|---|---|
| Daily free allowance | daily_free |
~24h after it lands |
| Welcome grant (for confirming your email) | signup_bonus |
90 days after it is paid |
| Plan bonuses | subscription |
at the end of each month of the term |
| Packs you buy, and anything an administrator grants you | purchase, adjust |
never, while your account is open — a lapsed or cancelled plan does not touch them |
A charge always consumes the soonest-expiring bonus first. A bonus that never expires is therefore always spent last: nothing you paid for is lost to a reset, and nothing you were given is left to evaporate while you still had a use for it. Where two kinds lapse at the same moment, they are spent in the order of the table above. Note that a monthly plan runs out before a 90-day welcome grant, so on a monthly term the plan bonuses go first — the rule is the expiry date, not the row order.
A plan pays out one monthly allowance at a time. Buying three, six or twelve months buys that many allowances — not one large balance. Each month's allowance is void the moment the next one lands: the remainder is replaced, not added to it. The last one is void when the term ends. So a 12-month Pro customer holds 6 000 bonuses at a time, twelve times over; never 72 000 at once.
expiring in the balance response is the part that lapses on expires_at; it
is not a separate wallet. On a multi-month term expires_at is the next monthly
boundary, not the end of the term — GET /billing/subscription has both dates.
The free tier is a daily allowance, not a plan. There is no free plan to
sign up for and no feature is switched off without one. An eligible account is
topped up with a small number of bonuses once a day, usable on anything the API
offers, and the FIRST allowance lands the moment the account is created — you
never wait on a scheduled pass to make your first call. Whether an account with
an unconfirmed address is eligible is a setting, so if your balance is zero
right after signing up, confirm your address. Like plan bonuses they are refreshed rather than accumulated —
yesterday's unused allowance is replaced by today's, not added to it, so the
daily figure is also the most you can hold. Confirming your email grants a
larger one-off amount on top (granted_credits in the confirm response), and
that welcome grant lapses 90 days after it is paid — it is a promotional bonus,
not a purchase. Ledger entries are typed daily_free, and the top-up stops
while a paid plan is active, since a plan already grants its own allowance.
Practical consequence for API clients: the free tier is fine for steady low-volume work and cannot do bursts. If you need to process a batch in one go, the bonuses have to come from a plan or a pack.
Renewals are manual unless the service says otherwise, and the shipped
answer is that they are. GET /public/config → billing.recurring_available
is the one field that says which product you are talking to, and everything you
build about renewal must read it rather than assume:
recurring_available: false— the default, and what this service does today. No payment method is kept on file,auto_renewis alwaysfalseand nothing is ever charged without you starting it, on a 12-month term too. Length is not a mandate: the term ends when the months you bought run out, the tier drops and the last allowance is swept. Buy the next term from/pricingbefore the current one ends.POST /billing/subscribeon the plan you already hold extends the term you are in, or starts a new one if it has ended.POST /billing/subscription/cancelanswers409 not_recurring, because there is nothing to cancel.recurring_available: true— automatic renewal can be bought. See the section below.
Automatic renewal
Only where billing.recurring_available is true.
Send auto_renew: true together with accept_recurring: true on
POST /billing/subscribe and the sale becomes a subscription: the payment
provider keeps the card — we never see it and never store it — and charges again
when the term ends, so the customer never has to come back. Omit both and you
get the ordinary fixed term; they default to false and always will.
accept_recurring is a THIRD consent tick, and it is required. The two
universal ones (accept_documents, accept_immediate) do not cover it:
Cal. B&P §17602(a)(1) wants the consumer's affirmative consent to the automatic
renewal terms specifically, and consent to the transaction as a whole is not
that. Send auto_renew: true without it and the order is refused with 400
naming the field, and nothing is opened — never quietly downgraded to a fixed
term. It is ignored entirely when auto_renew is false.
If you are building your own checkout, the box you show for it has to state, on the same screen and before the tick: what will be charged, how often, when the first renewal falls, and that it can be cancelled at any time from the account. Ours is unticked, always.
# a renewing subscription: three ticks, one order
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"plan_id":2,"months":1,
"accept_documents":true,"accept_immediate":true,
"auto_renew":true,"accept_recurring":true,"consent_locale":"en"}' \
https://mp.dvocorp.com/api/v1/billing/subscribe
It is refused with 409 {"code": "recurring_unavailable"}, never silently
downgraded to a fixed term, when the service does not sell subscriptions, when
the chosen rail cannot hold a mandate (any crypto rail), when the order is a
bundle (a pack cannot be bought over and over for ever), and when a paid term is
already running (buying the same plan then extends that term).
The charge repeats once per term; the credits still arrive monthly. This is
the one thing to get right. A 12-month subscription is billed once a year
and delivers twelve monthly allowances inside that year, each lapsing when
the next lands — exactly like a 12-month term bought outright. A renewal buys
another twelve. GET /billing/subscription carries both clocks and they are
usually far apart:
| Field | Means |
|---|---|
renews_at |
when the next charge falls due (the provider's own schedule), null when none is expected |
next_grant_at |
when the next monthly allowance lands |
renewal_months |
the charge cadence in months — 12 is one charge a year |
renewal_amount_cents / renewal_currency |
what will be charged, frozen at the moment you subscribed |
renewal_status |
off · active · past_due · paused · cancelling · ended |
cancel_at_period_end |
a cancellation is scheduled; the paid period is still yours |
grace_until |
a renewal charge failed — when access stops if it never succeeds |
can_cancel |
whether the cancel endpoint will answer right now |
Your price does not change under you. renewal_amount_cents is fixed when
the subscription is created and a later change to the price list reaches new
customers only.
# stop the next charge. The period you have paid for is untouched.
curl -X POST -H "Authorization: Bearer $JWT" \
https://mp.dvocorp.com/api/v1/billing/subscription/cancel
# -> {"auto_renew":false,"renewal_status":"cancelling",
# "cancel_at_period_end":true,"renews_at":null,
# "period_end":"2027-02-25T10:00:00Z","months_total":6,"can_cancel":false}
Cancelling is never a refund and never a clawback. period_end does not
move, every month you paid for is still delivered on its own boundary, and the
term closes on the day it was always going to close. Only the mandate ends.
409 {"code": "not_recurring"} means there was nothing renewing to stop.
The customer can always do it themselves, from the browser. The control
lives on their own account page at /profile#subscription — the same
medium they subscribed in, behind no menu and no support form, one click from
the balance chip in the header of every page. Read can_cancel before you
render a control of your own: it asks the same three questions the endpoint
raises on, so a button offered on it will never answer 409.
What we email a subscriber
Three letters, sent automatically. Each one states the amount, the date, and carries a link straight to the cancel control — you do not have to build any of them, and you should not send your own copies.
| When | What it says |
|---|---|
| Before a renewal charge | what will be taken, on what date, at what cadence, and how to stop it first |
| A renewal charge failed | that nothing was taken and nothing granted, how long the account keeps working (grace_until), and that the card is fixed with the payment provider |
| A cancellation | an acknowledgement, and the date access actually ends (period_end, which does not move) |
How much warning before a charge. Two operator settings, picked by the charge cadence, not by the grant cadence:
| Cycle | Setting | Default |
|---|---|---|
| 12 months or more | subscriptions_renewal_notice_days_annual |
30 days |
| shorter than a year | subscriptions_renewal_notice_days_short |
7 days |
The annual default sits in the middle of the 15–45 day window Cal. B&P §17602(b) and NY GBL §527-a(d) both require for terms of a year or more. The short one is a courtesy no statute requires — a charge nobody remembers agreeing to becomes a chargeback either way — and both are clamped to the cycle itself, so a window longer than the term can never make the notice arrive as a receipt for the charge that just happened.
A mail failure is never a billing failure: the letters are queued on the subscription row and retried, and nothing about a charge, a grant or a cancellation waits on one.
If a renewal charge fails — an expired card, a decline — the payment
provider retries on its own schedule and renewal_status becomes past_due.
Access continues until grace_until; no new allowance is granted in the
meantime, because no money arrived to pay for one, so the tier, the priority
and the balance you are still holding stay and nothing new is added. A charge
that lands clears the grace and delivers the cycle at once. If none ever does,
the term closes on the ordinary schedule and the last allowance is swept.
Refunds stop future months and never take back delivered ones. If a multi-month order is refunded, the months not yet handed out are withdrawn and the term ends at the close of the month you are in. Allowances already granted stay in your balance — nothing is clawed back.
Your price does not go up. The price and allowance you first pay for are
locked to your account for that plan. If the list price later rises, you keep
yours (is_locked_price: true, with list_price_cents showing what new
customers pay). If it falls below yours, you get the lower one.
Paying. redirect_url is a hosted checkout page — open it in a browser and
finish the payment there. Cards go through LiqPay, crypto through Coinbase
Commerce, and both can be open at the same time: GET /billing/providers is the
list you may pick from, provider on the checkout call is your pick, and the
response echoes the rail the order was opened on. Omit provider and you get
the default (is_default). A rail that is closed is refused
(400), never silently swapped for another one — nobody's card gets charged
because the crypto rail went down. Nothing else in the flow changes with the
rail, and an order already opened still settles even if that rail is switched
off while you are paying.
GET /billing/providers returns [] for the same reasons the price lists do:
no rail is open, or payments are not enabled for your account.
Prices are quoted in USD and may be charged in another currency. A card is
charged in the merchant account's settlement currency (UAH for the Ukrainian
merchant) at the rate shown on the checkout page, so the figure there can differ
from price_cents. Your order, your ledger and your receipts stay in the
currency the price was quoted in.
Payment states. status follows waiting → confirming → paid. Bonuses land
only on paid, which is driven by the provider's callback — expect it a moment
after the redirect returns, not instantly. A declined card leaves the order
waiting, not failed, precisely so you can retry the same order with another
card. An underpaid invoice becomes partially_paid and is not credited
automatically (a card cannot underpay; a crypto transfer can); contact support
with the order_id. expired and failed grant nothing and charge nothing.
You never lose a payment to a lost callback. If the provider's callback
never reaches us, open payments are polled independently and settled from the
provider's own record, so a paid order is credited either way — occasionally a
couple of minutes later than usual. Poll GET /billing/balance rather than
assuming the redirect meant delivery. If an order is still waiting 48 hours
after payment, that is the point to contact support with the order_id.
Plans and packs are hidden until payments are live. GET /billing/plans
and GET /billing/packages return [] when no payment provider is configured,
so you will never be quoted a price you cannot actually pay. Payments are also
gated per account: on an account that isn't cleared for billing yet, starting
a purchase (POST /billing/subscribe or POST /billing/checkout) is refused with
403 {"detail":"payments are not enabled for this account yet"}. Reading your
balance (GET /billing/balance, or GET /me/ping with a key) always works, and
so does spending it.
Packs can additionally require a plan (billing.packs_require_subscription
on GET /public/config). That is a rule about opening an order, not about the
balance: bonuses already bought never expire and stay spendable whatever happens
to a subscription.
Getting money back
Three endpoints, and like the rest of this section they take a panel session Bearer token, not an API key — withdrawing from a contract is an act of the person who concluded it.
| Endpoint | What it does |
|---|---|
GET /api/v1/billing/payments/{order_id}/refund-quote |
what this order would return right now, and why it would not |
POST /api/v1/billing/payments/{order_id}/refund |
withdraw from the contract, or ask for a refund under the policy |
GET /api/v1/billing/refunds |
your own requests and where each one stands |
Take the quote first. It is a read — it commits nobody to anything — and it is the only place the arithmetic is visible:
curl -s -H "Authorization: Bearer $TOKEN" \
https://mp.dvocorp.com/api/v1/billing/payments/ord_XXXX/refund-quote
{
"order_id": "ord_XXXX",
"currency": "USD",
"amount_cents": 1200,
"paid_cents": 2000,
"already_refunded_cents": 0,
"credits_sold": 1000,
"credits_returnable": 600,
"credits_spent": 400,
"reason": "withdrawal",
"deadline": "2026-09-19T10:04:11Z",
"withdrawal_deadline": "2026-09-19T10:04:11Z",
"blocked": "",
"eligible": true,
"payout_manual": false,
"open_refund_id": null
}
Then confirm:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"note":"bought the wrong pack"}' \
https://mp.dvocorp.com/api/v1/billing/payments/ord_XXXX/refund
What comes back is what you did not spend. Bonuses you already spent paid
for jobs that completed and the processing behind them has already run, so they
are not refundable — and they are never clawed back off your balance either.
The unspent part is returned in full, at the price you paid for it, and the
money is that same fraction of the charge. credits_spent on the quote is that
figure, printed rather than implied.
Bonuses are spent soonest-expiring first, so a purchased pack (which never expires) is the last thing touched. Burning a free daily allowance does not reduce what a pack is worth back.
A plan term also returns the months it has not delivered. Those were never granted, so nothing comes off the balance for them; the money for them goes back and the term stops accruing.
reason says which right you are exercising. "withdrawal" is the
statutory right to withdraw from a distance contract — 14 days — and is what the
control in the web app is labelled "withdraw from contract here" for.
"policy" is our own Refund Policy, which promises unused bonuses back for 30
days and is the broader of the two. Both are admin-set; read the dates off the
quote rather than counting days yourself.
blocked is machine-readable and amount_cents is then 0. not_settled
(the order never completed), window_closed, nothing_left (every bonus from
this order was spent — the request is answered, and the answer is nothing),
already_open (a request is with us), fully_refunded.
payout_manual: true means a person sends the money. Card orders are
refunded over the provider's API. A crypto order cannot be: a blockchain can
only be debited by the wallet's owner, so the refund is approved, the unspent
bonuses come off the balance, and the transfer is made by hand. You get an email
when it goes out.
You get the confirmation in writing. Asking sends an acknowledgement to your account email, and the outcome is emailed too — that is your record of the request, not the page you asked from.
A refund never returns more than the order charged, and a retried request never pays twice.