Reseller API
Provision and manage private labels from your own systems
Create private labels for your clients, sell them packages and add-ons, and read their usage and points. JSON over HTTPS, with OAuth2 client credentials.
# Exchange your API key for an access token
ACCESS_TOKEN=$(curl -s -X POST https://api-app.sign.net/api/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","api_key":"snk_live_..."}' \
| jq -r .access_token)
# Find your reseller's slug
SLUG=$(curl -s https://api-app.sign.net/console/dashboard \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq -r .data.slug)
# List your private labels
curl "https://api-app.sign.net/console/$SLUG/reseller/private-labels" \
-H "Authorization: Bearer $ACCESS_TOKEN"Two APIs, two jobs
Reseller API (Provision and billing)
Live todayCreate private labels for your clients, look after their people and branding, sell them packages and add-ons, and read their usage and points. Documented on this page.
Get startedSigning API (Embed sending and signing)
api.sign.netSend documents for signature and embed signing in your own product. Documented on api.sign.net.
Read the Signing API docs
Selling from WHMCS? Our WHMCS plugin creates, suspends, changes and deletes portals with your WHMCS services, so there's no code to write.
What you can build
Four common integrations, and the calls each one makes. Paths are relative to /console/{slug}/reseller, where {slug} is your reseller's domain.
Auto-provision a client after checkout
When an order is paid in your store, create the client's private label with the package they bought in one call, then add their team.
- POST
/private-labels - POST
/private-labels/:tenantId/users
- POST
Sell tiered packages with overage
Define Starter, Business and Pro packages at your own prices, and sell extra seats or documents as add-ons when a client goes over.
- POST
/billing/packages - POST
/billing/addons - POST
/private-labels/:tenantId/package/addons
- POST
Show usage on your own dashboard
Pull each client's monthly usage and points balance into your portal, so they see what they've used without leaving it.
- GET
/usage - GET
/private-labels/:tenantId/points - GET
/billing/quota
- GET
Pause a client who hasn't paid
When an invoice goes overdue, suspend the client's private label, and lift it when they pay. Their documents and package are kept.
- POST
/private-labels/:tenantId/suspend - POST
/private-labels/:tenantId/unsuspend
- POST
How the pieces fit
Everything in the API hangs off four concepts.
- Reseller
You. Sign.net gives you an allowance and a points pool.
/billing/quota · /billing/points - Private label
One per client, with its own owner, branding and members.
/private-labels - PackageAdd-ons
One package per private label, with any number of add-ons on top.
/billing/packages · /billing/addons - Points and quota
Documents, seats, templates and notarisations, counted in points.
/usage · /private-labels/:tenantId/points
Developer tools
- Endpoint reference
- API Playground
- Scoped API keys
- Rate limit docs
- SandboxComing soon
- OpenAPI and Postman downloadComing soon
- SDKsComing soon
- WebhooksComing soon
- ChangelogComing soon
- Status pageComing soon
Try it in the API Playground
The reseller console's API Playground builds a ready-to-run cURL command for any endpoint on this page and sends test requests with your key.
Open the API PlaygroundGetting started
Built on /api/v1/reseller? Those paths were retired on 1 October 2026 and now answer 404. Your key, the token exchange and the scopes are unchanged. Move each call to /console/{slug}/reseller (packages and add-ons now sit under /billing, read with …/search and …/get and changed with …/update), read results from the response's data, and expect camelCase field names.
Create an API key
In the reseller console, open Keys under API, pick the scopes your integration needs and create a key. Copy the secret straight away: it is only shown once. Every key is live, so requests made with it affect real data.
Exchange it for an access token
Post the key to
/api/v1/auth/tokenwithgrant_typeset toclient_credentials. You get back a short-lived Bearer token, andexpires_insays how many seconds it lasts.Find your slug
Call
GET /console/dashboardwith the token asAuthorization: Bearer. Itsdata.slugis your reseller's domain: the{slug}in every reseller path. It only changes if your domain does.Call the API
Send the same token on every reseller request. Answers come wrapped:
statusis"OK"with the result indata, or"Err"with anerror.codesuch asINVALID_REQUEST, usually as a400. When the token expires, after an hour, exchange your key again.
# 1. Exchange your API key for a short-lived access token
ACCESS_TOKEN=$(curl -s -X POST https://api-app.sign.net/api/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","api_key":"snk_live_..."}' | jq -r .access_token)
# 2. Find the slug of the reseller your key acts for
SLUG=$(curl -s https://api-app.sign.net/console/dashboard \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq -r .data.slug)
# 3. Call the endpoint
curl "https://api-app.sign.net/console/$SLUG/reseller/private-labels" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Token request
{
grant_type: "client_credentials"
api_key: string
}Token response 200
{
access_token: string
token_type: "Bearer"
expires_in: number
scope: string
}Scopes
Each key carries the scopes you pick when you create it, and each endpoint needs one of them. A call without the right scope is refused with 403 INSUFFICIENT_SCOPE. A key can't mint or revoke keys: someone signed in to the console does that.
reseller:read- Read your dashboard, private labels, their members, templates, packages, usage, quota and points, and your own packages, add-ons and keys.
reseller:provision- Provision, suspend, unsuspend and deprovision private labels, retry their hosts, add and remove their people, resend invites, and change their colours and logos.
reseller:packages- Create and update your packages and add-ons, and give them to private labels or take them off. Provisioning a label with a package needs this as well as reseller:provision.
Rate limits
Billing routes and the rest of the console have separate budgets, so a burst of label reads doesn't hold back a package assignment. A call that spends your allowance also counts against Allocation: assigning a package or attaching an add-on there and in Billing, provisioning there and in Console.
| Limit | Applies to | Counted per | Ceiling |
|---|---|---|---|
| Token exchange | Exchanging your API key for an access token | Your IP address | 5 every 15 minutes |
| Billing | Every billing/* route, and a private label's package and points routes | Your IP address | 60 a minute |
| Console | Every other route a key can call: labels, people, usage, branding, dashboard | Your IP address | 60 a minute |
| Allocation | Assigning a package, attaching an add-on, and provisioning with a package | Your reseller, across all its keys | 20 a minute |
Limited responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Going over answers 429 with a Retry-After header and a plain { "error": "…" } body rather than the usual envelope. Behind all of these, one address can make 300 calls a minute in total. You can hold up to 100 packages and 100 add-ons.
Building your integration
The conventions every endpoint follows, where to call it from, how to test without a sandbox, the path from an order to a live portal, and how to keep your records in step.
Conventions
Formats
- Requests
- JSON with
Content-Type: application/json, except logo uploads, which aremultipart/form-datasent tohttps://api.sign8.net. Field names are camelCase both ways. - Ids
- 32 lowercase hex characters, such as a
tenantId,packageIdoruserId. Store them as strings. - Times
- Milliseconds since the Unix epoch, such as
createdAtandexpiresAt. - Months
- A
periodis a calendar month in UTC, writtenYYYY-MM. Without one, you get the current month. - Money
- Whole minor units, such as cents, beside a three-letter ISO
currencysuch asSGD. Your prices are for you and your clients: Sign.net never charges with them. - Codes
- A package or add-on
codeis stored upper case and is unique among yours, sogrowthandGROWTHare the same code. - Hosts
- A
domainorprimaryHostis a bare hostname, such assign.acmelegal.com, with nohttps://or path.
What each item counts
documents- Documents sent in the period.
notarizations- Documents whose notarisation landed on chain in the period, whatever happened to them afterwards.
seats- People holding a seat on a label. Someone who was only sent a document to sign holds none.
templates- Templates a label holds.
points- What a label's people used from its points pool, less what they gave back. Measured, never billed.
The billed figures in GET …/billing/quota can read more than GET …/usage. Each label is billed the greater of what you allocated it and what it used, and your own tenant what it used, while usage counts only what was used.
Call it from your server
Browsers may only call the API from Sign.net's own sites and the portals it hosts, so a browser refuses a call from a page on your own website. That keeps your key where it belongs: anyone who can open a page or an app that holds it can read it.
- Keep the key in your server's secrets, such as an environment variable or a secrets manager. Never put it in your repository, or in anything you ship to a browser or phone.
- Keep one access token where all your server's workers can reach it, so a restart or a new worker reuses it rather than spending the token exchange's budget.
- Have your own pages and apps ask your server, and let your server call Sign.net.
Testing without a sandbox
There's no sandbox yet. Every key acts on your live reseller, and the API Playground sends real requests too, so build against your own account knowing which calls cost nothing and which are real.
Free to try as often as you like
- Reading anything
Every
GET, and package and add-on search. Build your reads with a key that holds onlyreseller:read, and it can't change anything. - Suspending and unsuspending
Signs a label's people out and lets them back in, with nothing on the label lost.
- Changing colours and logos
Send the old ones back to undo it.
Real, and counted against your allowance
- Provisioning a label
Creates a real portal and emails its owner, so use an owner address you control. Its seats and documents count against your allowance like any other label's, suspended or not, until you deprovision it.
- Assigning a package or attaching an add-on
Counts for the whole billing period, at the greater of what you allocated and what the label used, even if you take it off the same day. Test with the smallest quantities you can.
- Deprovisioning a label
Permanent, and its host can never be provisioned again, so give test labels hosts you won't want later, such as
test-1.yourdomain.com. - Adding people and sending invites
Emails each person a link to set a password. Use addresses you control.
- Creating packages and add-ons
They stay. Deactivating one keeps its code and its place among your 100, so give test ones a code you'll recognise, such as
TEST-STARTER.
From order to live portal
The calls an integration makes between a client paying and their portal opening.
Provision the label with
POST …/private-labels, sending theowner, theconfig(itsdomain,appNameand colours) and thepackagethey bought. Store thetenantIdandownerUserIdit answers with.Finish anything that didn't happen. If
package.outcomeisn'tAssigned, complete the assignment as Going over your allowance describes. IfdomainSetup.attachedisfalse, retry withPOST …/private-labels/:tenantId/domain/attach.Give your customer the DNS record: the first entry in
domainSetup.records, usually aCNAMEfrom their host tocname.vercel-dns.com, or anArecord for a whole domain. Any further entries are alternatives for the same name, and a name takes only one.Wait for DNS. Read
GET …/private-labels/:tenantIduntildomainSetup.verifiedistrue. It's refreshed at most once a minute and DNS can take hours to spread, so every few minutes is plenty.Make sure the owner can sign in. Their welcome email gives them the same record, then a link to set their password that lasts a day. If DNS took longer, send a fresh one with
POST …/private-labels/:tenantId/users/:userId/invite, using theownerUserId.Add their team with
POST …/private-labels/:tenantId/users. Each person is emailed a link to set a password.
Record each order before you call, so a lost answer sends you looking for the host in GET …/private-labels rather than provisioning twice.
Keeping in sync
There are no webhooks yet, so read back what can change without a call of yours. How often is a suggestion, not a limit.
| What changes | Where to read it | How often |
|---|---|---|
| A label's DNS goes live | domainSetup.verified from GET …/private-labels/:tenantId | Every few minutes, until it reads true |
| Sign.net suspends a label, or moves it to another reseller | status and suspendedBy from GET …/private-labels, or the label missing from it | Hourly: one call covers every label |
| A label's admin adds people in their portal | GET …/private-labels/:tenantId/members | Daily, or before you bill a client for seats |
| Your labels send documents, notarise them and take seats | GET …/usage?period=YYYY-MM | Hourly is plenty for a dashboard |
| Your remaining allowance | GET …/billing/quota | Before each assignment |
| Points pools renew and lapse | GET …/billing/points | Hourly: renewals and lapses are written once an hour |
Spread per-label reads out. From one address, the Console budget allows 60 calls a minute and the Billing budget, which includes a label's package and points, another 60.
Working with the API
How calls fail and what to do about each, how to go over your allowance on purpose, and which calls are safe to send again.
Status codes and errors
Nearly every answer comes in the same envelope: status reads "OK" with the result in data, or "Err" with an error.code and error.message. The exceptions are under the table.
| Status | Code | What it means | What to do |
|---|---|---|---|
| 200 | — | It worked, and data holds the result. Assigning a package or attaching an add-on can also answer 200 with outcome: "ConfirmationRequired", having written nothing. | Read data. On those two calls, check outcome as well. |
| 400 | INVALID_REQUEST, INVALID_TARGET, HOST_TAKEN, NOT_ASSIGNED, … | The request broke a rule or didn't decode. Each endpoint below lists the codes it can send. | Fix the request. Sent again unchanged, it fails the same way. |
| 401 | UNAUTHORIZED | The access token is missing, expired or invalid, or its key was revoked. | Exchange your key for a new token, then send the call once more. |
| 403 | INSUFFICIENT_SCOPE | Your key doesn't hold the scope the endpoint needs. | Mint a key with that scope. A key's scopes can't be changed later. |
| 403 | FORBIDDEN | The slug isn't your key's reseller, or the private label isn't one of yours, which includes one you've deprovisioned. | Check the slug against GET /console/dashboard and the tenantId against your list of private labels. |
| 403 | SESSION_REQUIRED | Only a person signed in to the console can do this, such as minting or revoking a key. | Do it in the console. |
| 404 | NOT_FOUND | No endpoint answers that method and path, such as a retired /api/v1/reseller path. | Check the method and path against the endpoints below. |
| 429 | { "error": "…" }, TOO_FREQUENT | You went over a rate limit, or asked to invite someone who was sent a set-password email in the last five minutes. | Wait the number of seconds in Retry-After, then send it again. |
| 500 | INTERNAL_SERVER_ERROR | Something failed on our side, and status reads "Fail". An invite whose email couldn't be sent answers 500 too. | Retry with a growing delay if the call is safe to repeat (below), and check what happened first if it isn't. Tell us if it keeps happening. |
- Branch on
error.code, noterror.message: the message is written for people and may change. - Two kinds of answer skip the envelope. The token exchange answers in OAuth2's format:
{ "error": "invalid_request" }(400, a missing or wronggrant_typeorapi_key) or{ "error": "invalid_client" }(401, a key that is unknown, revoked or mistyped). A rate limit's429is a bare{ "error": "…" }. - On the flat
/console/*endpoints, a call with no token, or a malformed one, answers400 JSON_WEB_TOKEN_ERROR, and400 UNAUTHORIZEDmeans thedomainyou sent isn't one of your private labels.
Going over your allowance
Assigning a package or attaching an add-on spends your Sign.net allowance straight away, whether or not the label uses it. Going past your allowance is allowed, but never by accident: the first call writes nothing and asks you to confirm.
Send the assignment as usual:
POST …/private-labels/:tenantId/packagewithpackageId, or…/package/addonswithaddonIdandquantity.If it fits, it's done, and
outcomereadsAssignedorAttached. If it doesn't,outcomereadsConfirmationRequiredand nothing has been written. Each warning gives an item, how much you asked for, how much you have left and how far over you'd go.To go ahead, send the identical body again with
confirmationKeyset toconfirmation.keywithin 10 minutes.expiresAtis when it runs out, in milliseconds.
Over your allowance 200
{
"status": "OK",
"data": {
"outcome": "ConfirmationRequired",
"confirmation": {
"key": "…",
"expiresAt": 1758800000000
},
"warnings": [
{
"itemCode": "seats",
"requested": 10,
"remaining": 3,
"excess": 7
}
]
}
}- A key belongs to the request it was issued for: the same label, the same package or add-on and the same quantity. It is only checked when a request would take you over: sent with a different one that would also go over, it answers
400 CONFIRMATION_INVALID, and with one that fits, it is ignored. A key over 10 minutes old answers400 CONFIRMATION_EXPIRED, so send the request without it for a new one. - If the numbers moved in between, because you edited the package or another assignment used up allowance, you get
ConfirmationRequiredagain with the new warnings and a fresh key, and still nothing is written. - Assignments sent at the same moment are checked one after another, so two that each fit on their own can't both go through unconfirmed.
- Provisioning with a
packagecan't take a key, because the label didn't exist yet. LeaveconfirmationKeyout. Ifpackage.outcomereadsConfirmationRequired, the label was created without its package: send the package to…/private-labels/:tenantId/packagewith the newtenantIdand the key it gave you. - To see what you have left before you assign, read
GET …/billing/quota: each item'sremaining.
Safe retries
Sending the same request again won't clear a 400 or a 403, so fix it first. A 429 clears after Retry-After. When an answer is lost to a timeout, a dropped connection or a 500, what to do depends on the call. Some answer an error the second time, which can simply mean the first one worked.
Sending again succeeds
- Suspend or unsuspend a private label
Answers the label's current state, whatever it was before. A label Sign.net suspended can't be unsuspended from here: that answers
400 SUSPENDED_BY_PLATFORM, however often you try. - Attach a label's host again
A host that's already attached answers as attached.
- Add someone with
POST …/private-labels/:tenantId/usersSomeone already on the label answers
200again, even at its seat cap, but withstatus: "added"andcreated: falsewhere the first answer said"created". - Remove someone with
DELETE …/private-labels/:tenantId/users/:userIdSomeone already removed answers
"removed"again. - Change colours, logos, a package or an add-on
Sets the same values again.
Sending again answers an error
- Provision a private label
Its host is taken once it exists, so a second try answers
HOST_TAKEN. Look for the host inGET …/private-labelsbefore trying again. - Assign a package or attach an add-on
A second assignment answers
ALREADY_ASSIGNED. A second attachment answersALREADY_ATTACHED, orConfirmationRequiredif the first used up the room it needed, so don't confirm it before readingGET …/private-labels/:tenantId/package. - Add someone with
POST /console/users/addA second try answers
400 ALREADY_IN_DOMAIN: they're already on the label. - Create a package or add-on
A second try answers
CODE_IN_USE. Find the first one with…/search. - Remove a package or add-on
A second try answers
NOT_ASSIGNEDorADDON_NOT_FOUND: it's already gone. - Send a fresh invite
Refused with
429 TOO_FREQUENTandRetry-Afterfor five minutes after any set-password email to that person, the one adding them sent included. A500means the email wasn't sent, and the five minutes still start. - Deprovision a private label
Permanent: its host can never be used again, and a second try answers
403 FORBIDDEN. To cut a client off for now, suspend the label instead.
Provisioning answers 200 once the label exists, even if part of the rest didn't happen. Check domainSetup.attached before telling a client their portal is live, and retry with …/domain/attach if it reads false. Check package.outcome too: Failed comes with the assignment's error, such as NO_SUBSCRIPTION or PACKAGE_INACTIVE. Fix what it names, then assign the package with …/package.
Keys and tokens
A key is shown once
It looks like
snk_live_<id>_<secret>and is shown only when it's minted. Keep it with your other secrets, never in code or a browser. It acts for one reseller, the one whose console minted it.Reuse each token for its hour
A token lasts
expires_inseconds, an hour. The token exchange allows 5 every 15 minutes from one address, so exchanging the key on every call soon answers429. Keep the token, and exchange again when it expires or a call answers401.Rotate without downtime
Mint the new key in the console, deploy it, then revoke the old one. Revoking a key ends its tokens at once, not when they expire.
Give a key only what it needs
Scopes are fixed when a key is minted. A key that only reads usage needs only
reseller:read. Keys can't mint or revoke keys, so a leaked key can't issue itself a successor.
Paging
- Package and add-on searches page in the body:
{ query, page, pageSize }, all three required.pagecounts from 1,pageSizeruns from 1 to 100, and aqueryof""lists everything. The answer carriestotal, so the last page is the one wherepage × pageSizereaches it. - Points ledgers page in the query:
?page=&pageSize=, newest first, withtotalbeside therows. Leave them out for the first 20 rows;pageSizegoes up to 100.itemCodenarrows them to one item. A value out of range answers400 INVALID_QUERY. - Rows written while you page push older ones down, so one can turn up on two pages. Each has an
idto tell them apart. - People search answers at most 50 matches and has no second page. Make the
querynarrower to reach someone past them. - Your private labels, and a label's members, come back whole.
Getting help with a failed call
Apart from the token exchange, answers carry no request id, so we find a call by when it was made and which key made it. Contact us with:
- When it happened, to the minute, with the time zone.
- The method and the full path you called, ids included.
- Your key's id: the 32 characters after
snk_live_, which aren't secret. Never send the part after them, or a token. - The status and
error.codeyou got back, and the body you sent, less any personal details you don't need to share. - For the token exchange, the
X-Request-Idheader of its answer: it is the one call that carries one.
Endpoints
Every path below is relative to https://api-app.sign.net, except logo uploads, which go to https://api.sign8.net. Replace :slug with your slug. Open an endpoint to see what it takes, what it returns and how it fails. Ids are 32-character hex strings, timestamps are milliseconds since the epoch and prices are in minor units, such as cents.
Dashboard and people
Find out which reseller your key acts for, and seat or remove people on the hosts you run.
GETDashboard/console/dashboardreseller:read
The private labels you administer, with how many people each seats. slug is the reseller you act for — the :slug in the reseller endpoints. A key sees its own reseller's labels only.
Response 200on success
{
status: "OK"
data: {
totalDomains: number
totalUsers: number
domains: { domain: string, companyName: string, userCount: number }[]
isReseller: boolean
slug: string | null
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The reseller your key was minted for has been deleted
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTSearch people/console/users/searchreseller:read
Finds people on the hosts you administer by email or name, up to 50. domain narrows the search to one host, and an empty query then lists everyone on it. A signed-in admin also searches the reseller's own staff; a key searches its private labels only.
Parameters
querystringOptionalSearch (empty for everyone)
domainstring (a host you administer)OptionalOnly this host
Response 200on success
{
status: "OK"
data: {
users: { id: string, firstName: string, lastName: string, email: string, status: string, domain: string }[]
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The reseller your key was minted for has been deleted
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTAdd a person to a host/console/users/addreseller:provision
Seats someone on a host you administer, creating their account if needed; they are emailed a link to set a password.
Parameters
emailstringRequiredEmail
firstNamestringRequiredFirst name
lastNamestringRequiredLast name
domainstring (a host you administer)RequiredDomain
Response 200on success
{
status: "OK"
data: {
message: string
}
}Errors
- 400
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }A domain you do not administer. A key administers its reseller's private labels only, not the reseller's own host
- 400
{ "status": "Err", "error": { "code": "DOMAIN_NOT_FOUND", "message": "…" } }No live tenant has that host
- 400
{ "status": "Err", "error": { "code": "ALREADY_IN_DOMAIN", "message": "…" } }They already hold a seat there
- 400
{ "status": "Err", "error": { "code": "SEAT_QUOTA_REACHED", "message": "…" } }The host has used every seat its package grants
- 400
{ "status": "Err", "error": { "code": "INVALID_EMAIL", "message": "…" } }Not one plain email address — refused before any account exists
- 400
{ "status": "Err", "error": { "code": "INVALID_NAME", "message": "…" } }A blank first or last name
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The reseller your key was minted for has been deleted
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTRemove a person from a host/console/users/removereseller:provision
Takes someone's seat on a host away and signs them out at once. Their account is kept, so adding them again seats it again, without any admin rights they held. Owners and admins cannot be removed, whether with an API key or signed in.
Parameters
emailstringRequiredEmail
domainstring (a host you administer)RequiredDomain
Response 200on success
{
status: "OK"
data: {
message: string
}
}Errors
- 400
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }A domain you do not administer. A key administers its reseller's private labels only, not the reseller's own host
- 400
{ "status": "Err", "error": { "code": "USER_NOT_FOUND", "message": "…" } }Nobody with that email is on that host
- 400
{ "status": "Err", "error": { "code": "CANNOT_REMOVE_OWNER", "message": "…" } }That is the owner or another of the admins here
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The reseller your key was minted for has been deleted
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
Branding
Change a private label's colours and logos.
POSTChange a host's colours/console/tenant/update-colorsreseller:provision
Changes a host's colours. Send only the ones to change; one left blank keeps its current colour. Colours are hex, #rgb or #rrggbb.
Parameters
domainstring (a host you administer)RequiredDomain
colorPrimarystring (hex)OptionalPrimary
colorPrimaryForegroundstring (hex)OptionalText on primary
colorAccentstring (hex)OptionalAccent
colorAccentForegroundstring (hex)OptionalText on accent
colorAppBarBackgroundstring (hex)OptionalApp bar
colorFooterBackgroundstring (hex)OptionalFooter
Response 200on success
{
status: "OK"
data: {
domain: string
message: string
}
}Errors
- 400
{ "status": "Err", "error": { "code": "DOMAIN_EMPTY", "message": "…" } }Blank domain
- 400
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }A domain you do not administer. A key administers its reseller's private labels only, not the reseller's own host
- 400
{ "status": "Err", "error": { "code": "NOT_FOUND", "message": "…" } }The host has no branding to change
- 400
{ "status": "Err", "error": { "code": "INVALID_COLOR", "message": "…" } }A colour that is not hex, #rgb or #rrggbb
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The reseller your key was minted for has been deleted
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTUpload a host's logos/console/tenant/upload-logosreseller:provision
Replaces a host's logo, its banner logo, or both. The body is multipart: the host goes in a data part as JSON, beside the image parts. Images are resized (the logo to fit 270×90, the banner to 500×500) and stored as PNG. Both are read before either is saved, so an image that cannot be read leaves the current logos as they are.
Send it as multipart/form-data to https://api.sign8.net rather than the usual API host: uploads to the usual host are blocked at its edge.
Parameters
data.domainstring (a host you administer)RequiredDomain
logo (file part)PNG, JPEG, GIF or SVG, up to 1 MBOptionalLogo
bannerLogo (file part)PNG, JPEG, GIF or SVG, up to 1 MBOptionalBanner logo
Response 200on success
{
status: "OK"
data: {
message: string
}
}Errors
- 400
{ "status": "Err", "error": { "code": "NO_FILES", "message": "…" } }Neither a logo nor a banner logo was attached
- 400
{ "status": "Err", "error": { "code": "DOMAIN_EMPTY", "message": "…" } }Blank domain
- 400
{ "status": "Err", "error": { "code": "INVALID_DATA", "message": "…" } }The data part is missing or is not JSON with a domain
- 400
{ "status": "Err", "error": { "code": "DOMAIN_INVALID", "message": "…" } }A domain that is not a host name
- 400
{ "status": "Err", "error": { "code": "INVALID_IMAGE", "message": "…" } }An image that cannot be read, or one larger than 4096 × 4096 pixels
- 400
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }A domain you do not administer. A key administers its reseller's private labels only, not the reseller's own host
- 400
{ "status": "Err", "error": { "code": "LIMIT_FILE_SIZE", "message": "…" } }An image over 1 MB
- 400
{ "status": "Err", "error": { "code": "LIMIT_UNEXPECTED_FILE", "message": "…" } }An image that is not PNG, JPEG, GIF or SVG, or a part it does not take
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The reseller your key was minted for has been deleted
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
Private labels
Provision a private label with its owner, branding and package, read it back, retry its host, suspend or unsuspend it, and deprovision it.
GETList private labels/console/:slug/reseller/private-labelsreseller:read
Every private label you provisioned, with its owner, package, template allowance, suspension and this period's counts. period is a month or lifetime, and defaults to this month.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
?period"YYYY-MM" | "lifetime"OptionalPeriod
Response 200on success
{
status: "OK"
data: {
privateLabels: {
tenantId: string
primaryHost: string
customDomain: string | null
adminEmail: string | null
assignedPackage: { packageId: string, code: string, name: string } | null
templateQuota: { used: number, total: number, unlimited: boolean }
metrics: { templates: number, documentsSent: number, members: number }
status: "Active" | "Suspended"
suspendedAt: number | null
suspendedBy: "Platform" | "Reseller" | null
suspendReason: string | null
}[]
provisionedCount: number
period: string
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTProvision a private label/console/:slug/reseller/private-labelsreseller:provision
Creates a private label under you, seats its owner (who is emailed a link to set a password) and attaches its host. It can also give the label one of your packages in the same call — with a key, that needs reseller:packages as well. The label is created first, so a refused assignment still leaves a working label, and package says what happened. Colours must be hex and links full http(s):// URLs, and a label cannot take one of sign.net's own hosts; all of that is checked before anything is created.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
owner.emailstringRequiredOwner email
owner.firstNamestringRequiredOwner first name
owner.lastNamestringRequiredOwner last name
config.domainstring (hostname)RequiredDomain
config.appNamestringRequiredApp name
config.supportUrlstring (URL)OptionalSupport URL
config.websiteUrlstring (URL)OptionalWebsite URL
config.colorPrimarystring (hex)OptionalPrimary color
config.colorAccentstring (hex)OptionalAccent color
package.packageIdstringOptionalPackage to assign
package.confirmationKeystringOptionalPackage confirmation key (only to go over your allowance)
Response 200on success
{
status: "OK"
data: {
tenantId: string
ownerUserId: string
domainSetup: { attached: boolean, verified: boolean, records: { type: string, name: string, value: string }[], note?: string }
package?:
| { outcome: "Assigned", assignmentId: string, allocated: { documents: number, seats: number, templates: number, notarizations: number } }
| { outcome: "ConfirmationRequired", confirmation: { key: string, expiresAt: number }, warnings: { itemCode: "documents" | "seats" | "templates" | "notarizations", requested: number, remaining: number, excess: number }[] }
| { outcome: "Failed", error: string }
}
}Errors
- 400
{ "status": "Err", "error": { "code": "HOST_TAKEN", "message": "…" } }That config.domain is already in use
- 400
{ "status": "Err", "error": { "code": "DOMAIN_INVALID", "message": "…" } }A config.domain that is not a hostname — not an email address or URL
- 400
{ "status": "Err", "error": { "code": "HOST_RESERVED", "message": "…" } }One of sign.net's own hosts, such as its app, console, admin, API or static host
- 400
{ "status": "Err", "error": { "code": "INVALID_COLOR", "message": "…" } }A colour that is not hex, #rgb or #rrggbb
- 400
{ "status": "Err", "error": { "code": "INVALID_URL", "message": "…" } }A websiteUrl, supportUrl or social link that is not a full http:// or https:// URL
- 400
{ "status": "Err", "error": { "code": "TOO_MANY_SOCIAL_LINKS", "message": "…" } }More than 20 social links
- 400
{ "status": "Err", "error": { "code": "OWNER_INVALID_EMAIL", "message": "…" } }The owner's email is not an email address
- 400
{ "status": "Err", "error": { "code": "OWNER_INVALID_NAME", "message": "…" } }The owner's first or last name is blank
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }Malformed body, a blank appName, or a value longer than its field
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }A key sending a package without reseller:packages — refused before anything is created
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETGet a private label/console/:slug/reseller/private-labels/:tenantIdreseller:read
One private label as a billing system shows it: whether it is suspended, who owns it, how many seats it holds, its package, and whether its host is served and its DNS is right (read live).
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
tenantId: string
primaryHost: string
customDomain: string | null
createdAt: number
status: "Active" | "Suspended"
suspendedAt: number | null
suspendedBy: "Platform" | "Reseller" | null
suspendReason: string | null
owner: { userId: string, email: string, firstName: string, lastName: string } | null
memberCount: number
assignment: {
id: string
packageId: string
packageCode: string
packageName: string
currency: string
assignedAt: number
addons: { subscriptionAddonId: string, addonId: string, code: string, name: string, quantity: number }[]
allocated: { documents: number, seats: number, templates: number, notarizations: number }
} | null
domainSetup: { attached: boolean, verified: boolean, records: { type: string, name: string, value: string }[], note?: string }
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTRetry attaching a private label's host/console/:slug/reseller/private-labels/:tenantId/domain/attachreseller:provision
Attaches a private label's host to the portal again. Provisioning attaches it without failing the provision, so a label can exist with a host that resolves nowhere (attached: false); this is the retry, and an attached host answers as attached.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
domainSetup: { attached: boolean, verified: boolean, records: { type: string, name: string, value: string }[], note?: string }
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
DELETEDeprovision a private label/console/:slug/reseller/private-labels/:tenantIdreseller:provision
Deletes a private label. It then leaves your subtree, so deleting it again answers 403.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
status: "deprovisioned"
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTSuspend a private label/console/:slug/reseller/private-labels/:tenantId/suspendreseller:provision
Signs every user of the label out and refuses sign-in until it is unsuspended. Its data and package are kept, and it is still billed. Suspending one already suspended keeps that suspension — suspendedBy is "Platform" when sign.net made it.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
reasonstring (up to 500 characters)OptionalReason (only you and sign.net see it)
Response 200on success
{
status: "OK"
data: {
tenantId: string
status: "Suspended"
suspendedAt: number
suspendedBy: "Reseller" | "Platform"
suspendReason: string | null
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A reason over 500 characters
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTUnsuspend a private label/console/:slug/reseller/private-labels/:tenantId/unsuspendreseller:provision
Lifts your suspension of a private label. Unsuspending one that is not suspended changes nothing and still succeeds.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
tenantId: string
status: "Active"
suspendedAt: null
suspendedBy: null
suspendReason: null
}
}Errors
- 400
{ "status": "Err", "error": { "code": "SUSPENDED_BY_PLATFORM", "message": "…" } }sign.net suspended it, and only sign.net can lift that
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETA private label's templates/console/:slug/reseller/private-labels/:tenantId/templatesreseller:read
The templates a private label's members made in its portal — a summary; you view them, you do not author them.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
templates: { id: string, title: string, slug: string, isPrivate: boolean, createdAt: number }[]
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
People on a private label
Add someone to a private label, list its members, remove someone, or send a fresh invite.
POSTAdd a member to a private label/console/:slug/reseller/private-labels/:tenantId/usersreseller:provision
Adds someone to a private label, creating their account if needed and emailing them a link to set a password. created is false when an account with that email was already on the label.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
emailstringRequiredEmail
firstNamestringRequiredFirst name
lastNamestringRequiredLast name
Response 200on success
{
status: "OK"
data: {
status: "created" | "added"
userID: string
created: boolean
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 400
{ "status": "Err", "error": { "code": "SEAT_QUOTA_REACHED", "message": "…" } }The label has used every seat its package grants
- 400
{ "status": "Err", "error": { "code": "INVALID_EMAIL", "message": "…" } }Not one plain email address — refused before any account exists
- 400
{ "status": "Err", "error": { "code": "INVALID_NAME", "message": "…" } }A blank first or last name
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETA private label's members/console/:slug/reseller/private-labels/:tenantId/membersreseller:read
Everyone holding a seat on a private label, owner first. role tells the owner from the label's other admins and its members; isOwner is true for every admin, as it always has been. People who were only sent a document take no seat and are left out.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
members: {
id: string
firstName: string
lastName: string
email: string
status: string
role: "owner" | "admin" | "member"
isOwner: boolean
createdAt: number
}[]
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
DELETERemove someone from a private label/console/:slug/reseller/private-labels/:tenantId/users/:userIdreseller:provision
Takes someone off a private label: their seat is revoked, they are signed out at once, and sign-in refuses them. Removing someone already removed answers the same, so a retry is safe. The owner and the label's other admins cannot be removed this way.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
:userIdstring (32-char hex)RequiredUser id
Response 200on success
{
status: "OK"
data: {
status: "removed"
}
}Errors
- 400
{ "status": "Err", "error": { "code": "CANNOT_REMOVE_OWNER", "message": "…" } }That user is the label's owner or another of its admins
- 400
{ "status": "Err", "error": { "code": "USER_NOT_FOUND", "message": "…" } }No one on this private label has that id
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTResend someone's invite/console/:slug/reseller/private-labels/:tenantId/users/:userId/invitereseller:provision
Emails someone on a private label a fresh link to set their password, in the label's branding. The one provisioning sent lasts a day, so a customer whose DNS was not live by then needs another. At most one every 5 minutes per person.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
:userIdstring (32-char hex)RequiredUser id
Response 200on success
{
status: "OK"
data: {
status: "sent"
}
}Errors
- 429
{ "status": "Err", "error": { "code": "TOO_FREQUENT", "message": "…" } }They were sent one in the last 5 minutes — wait for the Retry-After header
- 400
{ "status": "Err", "error": { "code": "USER_NOT_FOUND", "message": "…" } }No one on this private label has that id
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
API keys
See the keys you hold. Minting and revoking keys needs a person signed in to the console.
GETList your API keys/console/:slug/reseller/keysreseller:read
Your API keys, newest first — metadata only, never the secret.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
Response 200on success
{
status: "OK"
data: {
keys: { id: string, lastFour: string, scopes: string[], createdAt: number, lastUsedAt: number | null, revokedAt: number | null }[]
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
Your packages
The packages you sell to your private labels, with your own codes, prices, currency and billing cycle, and what each one includes.
POSTSearch your packages/console/:slug/reseller/billing/packages/searchreseller:read
The packages you sell to your private labels, a page at a time. sign.net neither sees nor charges these prices.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
querystringOptionalSearch (empty for all)
pagenumber (from 1)RequiredPage
pageSizenumber (1 to 100)RequiredPage size
Response 200on success
{
status: "OK"
data: {
packages: {
id: string
code: string
name: string
currency: string
billingCycle: "Monthly" | "Quarterly" | "Biannual" | "Annual" | "OneTime"
basePriceMinor: number
isActive: boolean
createdAt: number
updatedAt: number
}[]
total: number
page: number
pageSize: number
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTGet one of your packages/console/:slug/reseller/billing/packages/getreseller:read
One package with its items and description. assignedCount is how many live private labels hold it, which is what a price change would reach.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
idstring (32-char hex)RequiredPackage id
Response 200on success
{
status: "OK"
data: {
billingPackage: { id, code, name, description: string | null, currency, billingCycle, basePriceMinor, isActive, createdAt, updatedAt }
items: { itemCode: "documents" | "seats" | "templates" | "notarizations", includedQty: number, overageBundleSize: number, overageBundlePriceMinor: number }[]
assignedCount: number
}
}Errors
- 400
{ "status": "Err", "error": { "code": "PACKAGE_NOT_FOUND", "message": "…" } }No package of yours has that id — another reseller's reads the same as a missing one
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTCreate a package/console/:slug/reseller/billing/packagesreseller:packages
Adds a package to your catalogue. basePriceMinor is your price to your customer, in minor units; what the package costs you is the quantities it commits, which come off your sign.net allowance when it is assigned.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
codestringRequiredCode
namestringRequiredName
descriptionstring | nullRequiredDescription
currencystringRequiredCurrency
billingCycle"Monthly" | "Quarterly" | "Biannual" | "Annual" | "OneTime"RequiredBilling cycle
basePriceMinornumberRequiredYour package price (minor units)
items{ itemCode: "documents" | "seats" | "templates" | "notarizations", includedQty: number, overageBundleSize: number, overageBundlePriceMinor: number }[]RequiredItems
Response 200on success
{
status: "OK"
data: {
id: string
}
}Errors
- 400
{ "status": "Err", "error": { "code": "EMPTY_CODE", "message": "…" } }Blank code
- 400
{ "status": "Err", "error": { "code": "EMPTY_NAME", "message": "…" } }Blank name
- 400
{ "status": "Err", "error": { "code": "DUPLICATE_ITEM", "message": "…" } }The same itemCode twice
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }Malformed body, an unknown itemCode, a quantity outside 0–10,000,000, or more than four items
- 400
{ "status": "Err", "error": { "code": "CODE_IN_USE", "message": "…" } }You already have one with that code — codes are unique to you, not globally
- 400
{ "status": "Err", "error": { "code": "CATALOGUE_FULL", "message": "…" } }You already hold 100 — deactivating one frees its code, not its slot
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTUpdate a package/console/:slug/reseller/billing/packages/updatereseller:packages
Changes a package; only what you send changes. Code, currency and billing cycle are fixed once created. A new price reaches its holders from their next period.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
idstring (32-char hex)RequiredPackage id
namestringOptionalName
descriptionstring | nullOptionalDescription
isActivebooleanOptionalActive
basePriceMinornumberOptionalYour package price (minor units)
items{ itemCode: "documents" | "seats" | "templates" | "notarizations", includedQty: number, overageBundleSize: number, overageBundlePriceMinor: number }[]OptionalItems
Response 200on success
{
status: "OK"
data: {
message: "Success"
}
}Errors
- 400
{ "status": "Err", "error": { "code": "PACKAGE_NOT_FOUND", "message": "…" } }No package of yours has that id — another reseller's reads the same as a missing one
- 400
{ "status": "Err", "error": { "code": "EMPTY_NAME", "message": "…" } }Blank name
- 400
{ "status": "Err", "error": { "code": "DUPLICATE_ITEM", "message": "…" } }The same itemCode twice
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }Malformed body, an unknown itemCode, a quantity outside 0–10,000,000, or more than four items
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
Your add-ons
Extras you can attach on top of a package, each granting more of an item such as seats.
POSTSearch your add-ons/console/:slug/reseller/billing/addons/searchreseller:read
The add-ons you sell on top of your packages, a page at a time.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
querystringOptionalSearch (empty for all)
pagenumber (from 1)RequiredPage
pageSizenumber (1 to 100)RequiredPage size
Response 200on success
{
status: "OK"
data: {
addons: {
id: string
code: string
name: string
currency: string
priceMinor: number
billingCycle: "Monthly" | "Quarterly" | "Biannual" | "Annual" | "OneTime"
isActive: boolean
createdAt: number
updatedAt: number
}[]
total: number
page: number
pageSize: number
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTGet one of your add-ons/console/:slug/reseller/billing/addons/getreseller:read
One add-on with what it grants. areGrantsLocked turns true the first time it is attached anywhere; its price can still change after that, its grants cannot.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
idstring (32-char hex)RequiredAdd-on id
Response 200on success
{
status: "OK"
data: {
addon: { id, code, name, description: string | null, currency, priceMinor, billingCycle, isActive, createdAt, updatedAt }
items: { itemCode: "documents" | "seats" | "templates" | "notarizations", grantedQty: number }[]
areGrantsLocked: boolean
activeAttachments: number
}
}Errors
- 400
{ "status": "Err", "error": { "code": "ADDON_NOT_FOUND", "message": "…" } }No add-on of yours has that id — another reseller's reads the same as a missing one
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }A body that does not decode — a missing or mistyped field
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTCreate an add-on/console/:slug/reseller/billing/addonsreseller:packages
Adds an add-on to your catalogue. It must grant at least one item.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
codestringRequiredCode
namestringRequiredName
descriptionstring | nullRequiredDescription
currencystringRequiredCurrency
billingCycle"Monthly" | "Quarterly" | "Biannual" | "Annual" | "OneTime"RequiredBilling cycle
priceMinornumberRequiredYour add-on price (minor units)
items{ itemCode: "documents" | "seats" | "templates" | "notarizations", grantedQty: number }[]RequiredGrants
Response 200on success
{
status: "OK"
data: {
id: string
}
}Errors
- 400
{ "status": "Err", "error": { "code": "NO_GRANTS", "message": "…" } }An empty items list — an add-on must grant something
- 400
{ "status": "Err", "error": { "code": "EMPTY_CODE", "message": "…" } }Blank code
- 400
{ "status": "Err", "error": { "code": "EMPTY_NAME", "message": "…" } }Blank name
- 400
{ "status": "Err", "error": { "code": "DUPLICATE_ITEM", "message": "…" } }The same itemCode twice
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }Malformed body, an unknown itemCode, a quantity outside 0–10,000,000, or more than four items
- 400
{ "status": "Err", "error": { "code": "CODE_IN_USE", "message": "…" } }You already have one with that code — codes are unique to you, not globally
- 400
{ "status": "Err", "error": { "code": "CATALOGUE_FULL", "message": "…" } }You already hold 100 — deactivating one frees its code, not its slot
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTUpdate an add-on/console/:slug/reseller/billing/addons/updatereseller:packages
Changes an add-on; only what you send changes. What it grants can change only until it has been attached somewhere; its price can always change.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
idstring (32-char hex)RequiredAdd-on id
namestringOptionalName
descriptionstring | nullOptionalDescription
isActivebooleanOptionalActive
priceMinornumberOptionalYour add-on price (minor units)
items{ itemCode: "documents" | "seats" | "templates" | "notarizations", grantedQty: number }[]OptionalGrants
Response 200on success
{
status: "OK"
data: {
message: "Success"
}
}Errors
- 400
{ "status": "Err", "error": { "code": "ADDON_NOT_FOUND", "message": "…" } }No add-on of yours has that id — another reseller's reads the same as a missing one
- 400
{ "status": "Err", "error": { "code": "ADDON_IN_USE", "message": "…" } }items sent for an add-on that has ever been attached — create a new one instead
- 400
{ "status": "Err", "error": { "code": "NO_GRANTS", "message": "…" } }An empty items list
- 400
{ "status": "Err", "error": { "code": "EMPTY_NAME", "message": "…" } }Blank name
- 400
{ "status": "Err", "error": { "code": "DUPLICATE_ITEM", "message": "…" } }The same itemCode twice
- 400
{ "status": "Err", "error": { "code": "INVALID_REQUEST", "message": "…" } }Malformed body, an unknown itemCode, a quantity outside 0–10,000,000, or more than four items
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
Assigning packages
Give a private label one of your packages, attach add-ons to it, and take either off again. Assigning spends your allowance straight away, and going over it takes a second, confirmed request.
GETSee a private label's package/console/:slug/reseller/private-labels/:tenantId/packagereseller:read
The package and add-ons a private label holds, and how much of your allowance they take. assignment is null when it has none.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
assignment: {
id: string
packageId: string
packageCode: string
packageName: string
currency: string
assignedAt: number
addons: { subscriptionAddonId: string, addonId: string, code: string, name: string, quantity: number }[]
allocated: { documents: number, seats: number, templates: number, notarizations: number }
} | null
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTGive a private label a package/console/:slug/reseller/private-labels/:tenantId/packagereseller:packages
Gives a private label one of your packages. This spends your sign.net allowance immediately, whether or not the label uses it, and caps the label at what it was given. Going past your allowance takes a second call with the confirmation key.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
packageIdstringRequiredPackage
confirmationKeystringOptionalConfirmation key (only to go over your allowance)
Response 200on success
{
status: "OK"
data: {
outcome: "Assigned"
assignmentId: string
allocated: { documents: number, seats: number, templates: number, notarizations: number }
}
}Errors
- 200
{ "status": "OK", "data": { "outcome": "ConfirmationRequired", "confirmation": { "key": "…", "expiresAt": 1758800000000 }, "warnings": [{ "itemCode": "seats", "requested": 10, "remaining": 3, "excess": 7 }] } }It would take you past your allowance. NOTHING was written — send the identical body again with confirmationKey to go ahead
- 400
{ "status": "Err", "error": { "code": "ALREADY_ASSIGNED", "message": "…" } }That label already has a package — remove it first, or attach an add-on
- 400
{ "status": "Err", "error": { "code": "PACKAGE_NOT_FOUND", "message": "…" } }No package of yours has that id — another reseller's reads the same as a missing one
- 400
{ "status": "Err", "error": { "code": "PACKAGE_INACTIVE", "message": "…" } }That package is deactivated
- 400
{ "status": "Err", "error": { "code": "NO_SUBSCRIPTION", "message": "…" } }sign.net has not put you on a plan, so there is no allowance to assign from
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 400
{ "status": "Err", "error": { "code": "CONFIRMATION_INVALID", "message": "…" } }The confirmationKey was issued for a different request
- 400
{ "status": "Err", "error": { "code": "CONFIRMATION_EXPIRED", "message": "…" } }The confirmationKey is over 10 minutes old — send again without it for a new one
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
DELETERemove a private label's package/console/:slug/reseller/private-labels/:tenantId/packagereseller:packages
Takes a private label's package back and frees the allowance for future promises. The period it was held in is still billed for it.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
outcome: "Unassigned"
}
}Errors
- 400
{ "status": "Err", "error": { "code": "NOT_ASSIGNED", "message": "…" } }That private label has no package of yours — none at all, or one another reseller assigned before it moved to you
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
POSTAttach an add-on to a private label/console/:slug/reseller/private-labels/:tenantId/package/addonsreseller:packages
Tops a private label up without changing its package. Spends granted quantity × quantity of your allowance, the same way assigning does.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
addonIdstringRequiredAdd-on
quantitynumber (at least 1)RequiredQuantity
confirmationKeystringOptionalConfirmation key (only to go over your allowance)
Response 200on success
{
status: "OK"
data: {
outcome: "Attached"
subscriptionAddonId: string
}
}Errors
- 200
{ "status": "OK", "data": { "outcome": "ConfirmationRequired", "confirmation": { "key": "…", "expiresAt": 1758800000000 }, "warnings": [{ "itemCode": "seats", "requested": 10, "remaining": 3, "excess": 7 }] } }It would take you past your allowance. NOTHING was written — send the identical body again with confirmationKey to go ahead
- 400
{ "status": "Err", "error": { "code": "ALREADY_ATTACHED", "message": "…" } }That add-on is already on this label — remove it and attach it again to change the quantity
- 400
{ "status": "Err", "error": { "code": "NOT_ASSIGNED", "message": "…" } }That label has no package of yours — assign one before adding to it
- 400
{ "status": "Err", "error": { "code": "ADDON_NOT_FOUND", "message": "…" } }No add-on of yours has that id — another reseller's reads the same as a missing one
- 400
{ "status": "Err", "error": { "code": "ADDON_INACTIVE", "message": "…" } }That add-on is deactivated
- 400
{ "status": "Err", "error": { "code": "INVALID_QUANTITY", "message": "…" } }Quantity below 1 (a zero is a mistake, not a no-op), or one granting more than 10,000,000 of an item
- 400
{ "status": "Err", "error": { "code": "NO_SUBSCRIPTION", "message": "…" } }sign.net has not put you on a plan, so there is no allowance to assign from
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 400
{ "status": "Err", "error": { "code": "CONFIRMATION_INVALID", "message": "…" } }The confirmationKey was issued for a different request
- 400
{ "status": "Err", "error": { "code": "CONFIRMATION_EXPIRED", "message": "…" } }The confirmationKey is over 10 minutes old — send again without it for a new one
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
DELETERemove an add-on from a private label/console/:slug/reseller/private-labels/:tenantId/package/addons/:subscriptionAddonIdreseller:packages
Takes an add-on back off a private label, releasing what it allocated.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
:subscriptionAddonIdstring (32-char hex)RequiredAttached add-on id
Response 200on success
{
status: "OK"
data: {
outcome: "Removed"
}
}Errors
- 400
{ "status": "Err", "error": { "code": "NOT_ASSIGNED", "message": "…" } }That private label has no package of yours — none at all, or one another reseller assigned before it moved to you
- 400
{ "status": "Err", "error": { "code": "ADDON_NOT_FOUND", "message": "…" } }Nothing attached to this label has that subscriptionAddonId
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
Usage, quota and points
What your private labels used in a month, your allowance from Sign.net against what you have allocated, and your points pools and their ledgers.
GETUsage roll-up/console/:slug/reseller/usagereseller:read
What your private labels used in a month — documents, notarisations, points, seats and templates — in total and per label. Leave the period out for the current month.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
?periodstring (YYYY-MM)OptionalPeriod
Response 200on success
{
status: "OK"
data: {
period: string
total: { documents: number, notarizations: number, points: number, seats: number, templates: number }
privateLabels: {
tenantId: string
primaryHost: string
metrics: { documents: number, notarizations: number, points: number, seats: number, templates: number }
}[]
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_PERIOD", "message": "…" } }A period that is not YYYY-MM
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETYour allowance, and what you have allocated/console/:slug/reseller/billing/quotareseller:read
Your plan with sign.net, what you have promised out to your labels, and what they actually used — counts, never sign.net's prices. remaining is what is left once what is allocated to your labels, what your own team used (ownUsed) and what labels used beyond their packages (labelsBeyondAllocation) are taken off: assigning spends a label's share straight away, so its usage inside the package takes nothing more. subscription is null when sign.net has not put you on a plan.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
Response 200on success
{
status: "OK"
data: {
subscription: { packageName: string, billingCycle: string } | null
window: { from: number, to: number } | null
items: {
itemCode: "documents" | "seats" | "templates" | "notarizations"
includedQty: number
allocatedNow: number
used: number
ownUsed: number
labelsBeyondAllocation: number
remaining: number
overAllocatedBy: number
}[]
byPrivateLabel: {
tenantId: string
primaryHost: string
allocatedInPeriod: { documents: number, seats: number, templates: number, notarizations: number }
actual: { documents: number, seats: number, templates: number, notarizations: number }
billed: { documents: number, seats: number, templates: number, notarizations: number }
}[]
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETYour points pool/console/:slug/reseller/billing/pointsreseller:read
Your points pool and each private label's. Units are the truth; points are units × sign.net's rate card. A label with hasPool: false has no package and spends from drawsFrom — you. Counts and points only, never prices.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
Response 200on success
{
status: "OK"
data: {
reseller: {
tenantId: string
primaryHost: string
hasPool: boolean
drawsFrom: string | null
buckets: { itemCode: "documents" | "seats" | "templates" | "notarizations", units: number, pointsPerUnit: number, points: number, activity: { Grant, Transfer, Return, Consume, Release, Overage, Revoke, Adjust, Expire, Opening: number }, carried: number | null, nextLapse: { units: number, at: number } | null }[]
}
cycle: { start: number, end: number | null } | null
rates: { documents: number, seats: number, templates: number, notarizations: number }
privateLabels: {
tenantId: string
primaryHost: string
hasPool: boolean
drawsFrom: string | null
buckets: { itemCode: "documents" | "seats" | "templates" | "notarizations", units: number, pointsPerUnit: number, points: number, activity: { Grant, Transfer, Return, Consume, Release, Overage, Revoke, Adjust, Expire, Opening: number }, carried: number | null, nextLapse: { units: number, at: number } | null }[]
}[]
}
}Errors
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETYour points ledger/console/:slug/reseller/billing/points/ledgerreseller:read
Every movement on your own buckets, newest first, a page at a time. itemCode narrows it to one item.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
?itemCode"documents" | "seats" | "templates" | "notarizations"OptionalItem
?pagenumber (from 1)OptionalPage
?pageSizenumber (1 to 100)OptionalPage size
Response 200on success
{
status: "OK"
data: {
rows: {
id: string
tenantId: string
itemCode: "documents" | "seats" | "templates" | "notarizations"
kind: "Grant" | "Transfer" | "Return" | "Consume" | "Release" | "Overage" | "Revoke" | "Adjust" | "Expire" | "Opening"
units: number
pointsPerUnit: number
counterpartTenantId: string | null
sourceType: string
sourceId: string | null
cycleStart: number | null
expiresAt: number | null
bundleSize: number | null
isCapped: boolean | null
note: string | null
createdAt: number
}[]
total: number
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_QUERY", "message": "…" } }page below 1, pageSize outside 1–100, or an unknown itemCode
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETA private label's points/console/:slug/reseller/private-labels/:tenantId/pointsreseller:read
One private label's buckets, or — for a label with no package — the pool it spends from.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
Response 200on success
{
status: "OK"
data: {
pool: {
tenantId: string
primaryHost: string
hasPool: boolean
drawsFrom: string | null
buckets: { itemCode: "documents" | "seats" | "templates" | "notarizations", units: number, pointsPerUnit: number, points: number, activity: { Grant, Transfer, Return, Consume, Release, Overage, Revoke, Adjust, Expire, Opening: number }, carried: number | null, nextLapse: { units: number, at: number } | null }[]
}
cycle: { start: number, end: number | null } | null
rates: { documents: number, seats: number, templates: number, notarizations: number }
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
GETA private label's points ledger/console/:slug/reseller/private-labels/:tenantId/points/ledgerreseller:read
Every movement on one private label's buckets, newest first, a page at a time.
Parameters
:slugstring (your primaryHost)RequiredYour reseller's domain
:tenantIdstring (32-char hex)RequiredPrivate label tenant id
?itemCode"documents" | "seats" | "templates" | "notarizations"OptionalItem
?pagenumber (from 1)OptionalPage
?pageSizenumber (1 to 100)OptionalPage size
Response 200on success
{
status: "OK"
data: {
rows: {
id: string
tenantId: string
itemCode: "documents" | "seats" | "templates" | "notarizations"
kind: "Grant" | "Transfer" | "Return" | "Consume" | "Release" | "Overage" | "Revoke" | "Adjust" | "Expire" | "Opening"
units: number
pointsPerUnit: number
counterpartTenantId: string | null
sourceType: string
sourceId: string | null
cycleStart: number | null
expiresAt: number | null
bundleSize: number | null
isCapped: boolean | null
note: string | null
createdAt: number
}[]
total: number
}
}Errors
- 400
{ "status": "Err", "error": { "code": "INVALID_QUERY", "message": "…" } }page below 1, pageSize outside 1–100, or an unknown itemCode
- 400
{ "status": "Err", "error": { "code": "INVALID_TARGET", "message": "…" } }The tenant id is not a live private label
- 401
{ "status": "Err", "error": { "code": "UNAUTHORIZED", "message": "…" } }Missing, expired or revoked access token — exchange your API key again
- 403
{ "status": "Err", "error": { "code": "INSUFFICIENT_SCOPE", "message": "…" } }Your key does not hold the scope above
- 403
{ "status": "Err", "error": { "code": "FORBIDDEN", "message": "…" } }The slug is not your key's reseller, or the target is outside your subtree
- 429
{ "error": "Too many requests, please try again later." }More than 60 calls a minute from your address — wait for the Retry-After header
Sell portals from WHMCS
The Sign.net Reseller plugin for WHMCS calls this API for you, so there's no code to write. A paid order creates the portal, and it is suspended, changed and deleted with its service. You manage your packages and add-ons, and see your allowance, inside WHMCS.
How it works
Each WHMCS service is one private-label portal, on the address your customer chose at checkout, holding one of your Sign.net packages. On their service page, your customer sees the portal's state, the DNS records to create, each with a Copy button, and what their package includes.
What each WHMCS action does
- Order paid, or Create
- Creates the portal on the order's Portal address, with the client as its owner, the product's package and the add-ons they chose. The owner is emailed a link to set a password.
- Suspend, Unsuspend
- Suspends the portal with WHMCS's reason, which you and Sign.net see and its people never do, or lifts it. Only Sign.net can lift a suspension Sign.net made.
- Change Package
- Swaps the package and changes add-on quantities to match, for upgrades, downgrades and configurable options.
- Terminate
- Deletes the portal for good. Its hostname can never be used again.
What you need
- WHMCS 8.13 LTS on PHP 8.1 to 8.3, or WHMCS 9.0 on PHP 8.2 or later.
- The PHP extensions
curlandjson.intlis optional: it converts internationalised portal addresses, such asbücher.example, to theirxn--form. - A Sign.net plan on your reseller account. Without one, Sign.net refuses to assign packages.
- An API key holding all three scopes:
reseller:read,reseller:provisionandreseller:packages.
Install it
Download
signnet-whmcs-plugin-<version>.zipfrom the latest release and unzip it. Copy itsmodules/servers/signnet/andmodules/addons/signnet_reseller/folders into your WHMCS root, keeping their paths. Nothing else is needed: the plugin uses no Composer at runtime.Activate the addon: System Settings → Addon Modules → Sign.net Reseller → Activate. Then click Configure, give your admin roles access, and set the branding new portals start with and how low an allowance gets before the dashboard warns you.
Add the Sign.net server: System Settings → Servers → Add New Server. Choose the module Sign.net Private Label, enter
api-app.sign.netas the hostname and yoursnk_live_…key as the password, and tick Secure. Click Test Connection, then add the server to a server group.Create a package: Addons → Sign.net Reseller → Packages → New package, with a code, a name, the billing cycle and price you sell it at, and what it includes. A code can never be reused, even after the package is archived.
Turn it into a product: click WHMCS product on the package's row, choose a product group and click Create product. The product is set up when the order is paid, sends the Sign.net Portal Welcome email, is priced from the package, and asks for a Portal address and Portal name at checkout.
Sell add-ons, if you like: Addons → Sign.net Reseller → Add-ons → New add-on. Click Configurable option on its row, tick the products that offer it and click Create configurable option. Keep the
addon_<CODE>|part of the option's name: it's how the plugin finds the add-on.
The product's Module Settings
- Sign.net package
- Your active packages, read live from Sign.net.
- Orders over the allowance
- Hold for approval, the default: an order that would take you past your Sign.net allowance waits, and nothing is created until you approve it. Confirm automatically: it goes ahead, and you're billed for the excess.
- Automated termination
- Only after a cancellation request, the default: the cron deletes a portal only when the client asked to cancel. Always: on any termination, overdue ones included. Never: only an administrator deletes. An administrator's own Terminate always deletes.
Running it
On the service page
- Approve over-allowance & retry
Lets an order held over your allowance go ahead. A held order stays Pending, its Create error starts
[signnet:held], and it's listed on the addon's dashboard. - Resend owner invite
The owner's link to set a password expires after 24 hours. Sign.net sends at most one every five minutes.
- Retry domain attach
The portal's address shows Not attached.
- Apply plan
The portal's package or add-ons don't match the service, such as after you change the product's package.
- Link existing portal
The portal already exists, such as one you made in the reseller console. Put its tenant id in the service's Username field, or its address in Domain, save, then click.
Limits to know
- There's no sandbox
The portals a key creates are real, and so is what they use.
- A hostname is used once
One that was ever used, even by a deleted portal or another reseller, can never be used again. Checkout can only check the hostnames your own WHMCS knows about.
- About 20 new portals a minute
Per reseller account, shared by all its keys, and fewer when orders carry add-ons. Retry a refused order from WHMCS's module queue: a retry never makes a second portal.
- Package swaps aren't atomic
For a moment the portal holds no package, and the credit it carried is written off. If the new package can't be assigned, the old one and its add-ons are put back.
- Token requests
Sign.net allows 5 every 15 minutes from one address. The plugin shares one token across WHMCS, and after a refused key it waits five minutes before trying that key again, Test Connection included.
To upgrade, copy a new release's module folders over the old ones: the addon adds any tables and columns it needs and never removes any. The welcome email's merge fields, held orders and what to do when a call fails are all in the plugin's install guide.