Partner Business Registration API
Register a new business on HeldSway from an authorized external partner platform. The request payload is encrypted using a shared secret for secure server-to-server communication.
Prerequisites
The partner endpoints in this part (Check Business, Register Business, Set Password) are available exclusively to authorized partners with a pre-shared PARTNER_TOKEN. Each route uses partner.auth:rentmy and throttle:10,1.
Base URL: every path in this document is the full path on the API host, https://api.heldsway.com (); for example POST /api/v1/partner/check-business is https://api.heldsway.com/api/v1/partner/check-business.
| Requirement | Details |
|---|---|
| Authentication | Authorization: Bearer <PARTNER_TOKEN> header |
| Encryption | Request body must be AES-256-CBC encrypted using the same PARTNER_TOKEN |
| Rate Limit | 10 requests per minute per IP |
Encryption Specification
All request payloads must be encrypted before transmission. This provides a dual-layer security model: the Bearer token authenticates the caller, and the encrypted payload ensures data integrity and confidentiality.
Algorithm
| Parameter | Value |
|---|---|
| Cipher | AES-256-CBC |
| Key Derivation | SHA-256(PARTNER_TOKEN) — produces a 32-byte encryption key |
| IV | 16 bytes, randomly generated per request |
| Integrity | HMAC-SHA256 over iv_bytes + ciphertext_bytes using the derived key |
Encryption Steps
- Derive the key:
key = SHA-256(PARTNER_TOKEN)(binary, 32 bytes) - Generate IV: 16 random bytes
- Encrypt:
ciphertext = AES-256-CBC(JSON_payload, key, iv)usingOPENSSL_RAW_DATAflag - Compute MAC:
mac = HMAC-SHA256(iv_bytes + ciphertext_bytes, key)— hex-encoded, 64 characters - Encode: Base64-encode both
ciphertextandiv
Reference Implementation (PHP)
$token = 'your-partner-token';
$key = hash('sha256', $token, binary: true);
$iv = random_bytes(16);
$plaintext = json_encode([
'business_name' => 'Acme Rentals',
'owner_name' => 'John Doe',
'email' => 'john@acme.com',
'phone' => '+1234567890',
'address' => '123 Main St, City, ST 12345',
'website_url' => 'https://acme.com',
]);
$ciphertext = openssl_encrypt($plaintext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
$mac = hash_hmac('sha256', $iv . $ciphertext, $key);
$requestBody = [
'payload' => base64_encode($ciphertext),
'iv' => base64_encode($iv),
'mac' => $mac,
];
Reference Implementation (Node.js)
const crypto = require('crypto');
const token = 'your-partner-token';
const key = crypto.createHash('sha256').update(token).digest(); // 32-byte Buffer
const data = JSON.stringify({
business_name: 'Acme Rentals',
owner_name: 'John Doe',
email: 'john@acme.com',
phone: '+1234567890',
address: '123 Main St, City, ST 12345',
website_url: 'https://acme.com',
});
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const ciphertext = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
const mac = crypto.createHmac('sha256', key).update(Buffer.concat([iv, ciphertext])).digest('hex');
const requestBody = {
payload: ciphertext.toString('base64'),
iv: iv.toString('base64'),
mac: mac,
};
Reference Implementation (Python)
import hashlib, hmac, json, os, base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
token = "your-partner-token"
key = hashlib.sha256(token.encode()).digest() # 32 bytes
data = json.dumps({
"business_name": "Acme Rentals",
"owner_name": "John Doe",
"email": "john@acme.com",
"phone": "+1234567890",
"address": "123 Main St, City, ST 12345",
"website_url": "https://acme.com",
}).encode()
# PKCS7 padding (AES-CBC requires block-aligned input)
padder = padding.PKCS7(128).padder()
padded = padder.update(data) + padder.finalize()
iv = os.urandom(16)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
mac = hmac.new(key, iv + ciphertext, hashlib.sha256).hexdigest()
request_body = {
"payload": base64.b64encode(ciphertext).decode(),
"iv": base64.b64encode(iv).decode(),
"mac": mac,
}
Check Business
Non-mutating preflight for the two-phase connect flow (source: App\Http\Controllers\Api\V1\Partner\CheckBusinessController → PartnerRegistrationService::checkBusiness()). It decrypts and validates the same payload Register Business takes, then reports whether a HeldSway business already exists for the owner email. Show the result to the user, then call Register Business with the action they choose. Nothing is created or changed.
POST /api/v1/partner/check-business
Authorization: Bearer <PARTNER_TOKEN>
Content-Type: application/json
Auth: partner.auth:rentmy (partner Bearer token, no scope) Rate limit: 10 requests/minute per IP (throttle:10,1)
Request Body
Rules from CheckBusinessRequest::rules().
| Field | Type | Required | Description |
|---|---|---|---|
payload |
string | Yes | Base64-encoded AES-256-CBC ciphertext (see Encryption Specification) |
iv |
string | Yes | Base64-encoded 16-byte initialization vector |
mac |
string | Yes | 64-character hex HMAC-SHA256 digest |
store_id |
string | No | Max 64. Unauthenticated duplicate of the payload field; a JSON integer is converted to a string. The payload value wins |
location_id |
string | No | Max 64. Same rules as store_id |
location_name |
string | No | Max 255. Unauthenticated duplicate of the payload field; the payload value wins |
store_id, location_id and location_name are also read from the query string. For each one the encrypted payload wins, then the JSON body, then the query string.
Decrypted Payload Fields
Validated after decryption by PartnerRegistrationService::validateBusinessData() (shared with Register Business).
| Field | Type | Required | Constraints |
|---|---|---|---|
business_name |
string | Yes | max 255 |
owner_name |
string | Yes | max 255 |
email |
string | Yes | valid email, max 255 |
phone |
string | No | max 50 |
address |
string | No | max 500 |
website_url |
string | Yes | valid URL, max 255 |
store_id |
string | No | max 64 (a JSON integer is converted to a string) |
location_id |
string | No | max 64 (a JSON integer is converted to a string) |
location_name |
string | No | max 255 |
Success Response — 200 OK (no existing business)
{
"success": true,
"message": "OK",
"data": {
"exists": false,
"business_summary": null,
"user_exists": true,
"data_to_be_shared": {
"business_name": "Acme Rentals",
"email": "john@acme.com",
"owner_name": "John Doe",
"phone": "+1234567890",
"address": "123 Main St, City, ST 12345",
"website_url": "https://acme.com"
}
}
}
user_exists is true when a HeldSway user with this email already exists. Register Business with action: "create_new" creates the business under that user.
Success Response — 200 OK (another location for a connected store)
Returned when an active business owned by this email is already bound to the same store_id and the request carries a location_id:
{
"success": true,
"message": "OK",
"data": {
"exists": true,
"connected": true,
"can_add_location": true,
"store_id": "4821",
"business_summary": { "name": "Acme Rentals", "slug": "acme-rentals", "status": "active" },
"locations": [
{ "id": "1", "name": "Downtown" }
]
}
}
locations lists the locations already connected to the business.
Existing business (409)
Any other business owned by this email that passes the blocked-state checks in the Error Reference below (not suspended, same partner and email, same or no store) is reported as EXISTING_INACTIVE_BUSINESS, whether it is active, inactive, disconnected or soft-deleted. The partner can then offer “reconnect” or “create new”. The checks run first (PartnerRegistrationService::guardBlockedStates()): a suspended business returns BUSINESS_LOCKED, a partner or email mismatch returns ALREADY_CONNECTED / PARTNER_MISMATCH / EMAIL_MISMATCH, and a different store returns STORE_MISMATCH.
{
"success": false,
"error": {
"code": "EXISTING_INACTIVE_BUSINESS",
"message": "Business is inactive. You can reconnect or create a new one.",
"details": {
"existing_business": { "name": "Acme Rentals", "slug": "acme-rentals", "status": "disconnected", "deleted": false }
}
}
}
Error Reference
Mapped by the controller and MapsPartnerErrors::mapCommonPartnerError().
| HTTP Status | Error Code | Cause |
|---|---|---|
400 |
DECRYPTION_FAILED |
Bad base64 in payload or iv, wrong IV length, or MAC mismatch |
401 |
UNAUTHORIZED |
Missing or invalid partner Bearer token |
409 |
EXISTING_INACTIVE_BUSINESS |
A business owned by this email exists (see above); details.existing_business describes it |
409 |
ALREADY_CONNECTED |
The email’s active business was connected by a different partner, or its connected email differs; details.existing_business describes it |
409 |
PARTNER_MISMATCH |
The email’s (inactive or deleted) business was connected by a different partner |
409 |
EMAIL_MISMATCH |
The email’s (inactive or deleted) business was connected with a different email |
409 |
BUSINESS_LOCKED |
The email’s business is suspended |
409 |
STORE_MISMATCH |
The email’s business is bound to a different store_id |
409 |
ACCOUNT_DEACTIVATED |
The HeldSway user with this email has been deactivated |
422 |
VALIDATION_ERROR |
A decrypted payload field is missing or invalid; message “Invalid business data in encrypted payload.”, per-field messages in details |
422 |
— | Missing or malformed envelope fields, rejected by CheckBusinessRequest with Laravel’s standard validation body (message + errors), no code |
429 |
— | More than 10 requests/minute from this IP. Laravel’s throttle response: {"message": "Too Many Attempts."} with a Retry-After header |
500 |
CHECK_FAILED |
Unexpected error that is not a RuntimeException |
503 |
PARTNER_DISABLED |
Partner integration is disabled |
503 |
PARTNER_NOT_CONFIGURED |
Partner token is not set on the server |
A RuntimeException the mapper does not recognise is re-thrown and returns Laravel’s generic 500 body instead of CHECK_FAILED.
Register Business
POST /api/v1/partner/register-business
Authorization: Bearer <PARTNER_TOKEN>
Content-Type: application/json
Request Body (Encrypted Envelope)
| Field | Type | Required | Description |
|---|---|---|---|
payload |
string | Yes | Base64-encoded AES-256-CBC ciphertext |
iv |
string | Yes | Base64-encoded 16-byte initialization vector |
mac |
string | Yes | 64-character hex HMAC-SHA256 digest |
action |
string | Yes | reconnect or create_new — the choice the user made after Check Business |
consent_given |
boolean | Yes | Must be accepted (true) |
store_id |
string | No | Max 64. Unauthenticated duplicate of the payload field; the payload value wins |
location_id |
string | No | Max 64. Same rules as store_id |
location_name |
string | No | Max 255. Same rules as store_id |
Rules from RegisterBusinessRequest::rules().
Decrypted Payload Fields
These fields must be JSON-encoded, then encrypted as described above.
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
business_name |
string | Yes | max 255 chars | Name of the business |
owner_name |
string | Yes | max 255 chars | Full name of the business owner |
email |
string | Yes | valid email, max 255 chars | Owner’s email address |
phone |
string | No | max 50 chars | Owner’s phone number |
address |
string | No | max 500 chars | Business address |
website_url |
string | Yes | valid URL, max 255 chars | Business website |
store_id |
string | No | max 64 chars | Partner store ID (a JSON integer is converted to a string) |
location_id |
string | No | max 64 chars | Partner location ID (a JSON integer is converted to a string) |
location_name |
string | No | max 255 chars | Partner location name |
Example
curl -X POST https://api.heldsway.com/api/v1/partner/register-business \
-H "Authorization: Bearer <PARTNER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"payload": "dGhpcyBpcyBhIGJhc2U2NC1lbmNvZGVkIGNpcGhlcnRleHQ=",
"iv": "c29tZS1iYXNlNjQtaXY=",
"mac": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"action": "create_new",
"consent_given": true
}'
Success Response
HTTP 201 Created
{
"success": true,
"message": "Business registered successfully.",
"data": {
"linked": false,
"business": {
"id": 42,
"name": "Acme Rentals",
"slug": "acme-rentals",
"url": "https://acme-rentals.heldsway.com"
},
"credentials": {
"api_key": "as_aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPqRsTu",
"api_secret": "aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkL",
"access_token": "xYzAbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGh",
"token_type": "Bearer",
"expires_in": 3153600000,
"scopes": [
"affiliates:read",
"affiliates:write",
"conversions:read",
"conversions:write",
"clicks:read",
"clicks:write",
"commissions:read",
"reports:read",
"sdk:init",
"pageviews:write",
"orders:read",
"orders:write",
"settings:read",
"settings:write",
"connections:read",
"connections:write",
"payouts:read",
"payouts:write"
]
},
"owner": {
"email": "john@acme.com",
"name": "John Doe"
},
"password_setup": {
"token": "q8Zr1vXk…64 characters",
"expires_in": 900
}
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
business.id |
integer | Unique business identifier |
business.name |
string | Business display name |
business.slug |
string | URL-safe subdomain slug |
business.url |
string | Business panel URL from Business::getUrl(): the verified custom domain, else {slug}.{MAIN_DOMAIN}, with the scheme of APP_URL |
credentials.api_key |
string | Public API key (prefix: as_). Safe to store. |
credentials.api_secret |
string | Sensitive. Shown only once. Store securely. |
credentials.access_token |
string | Short-lived Bearer token for immediate API use |
credentials.token_type |
string | Always "Bearer" |
credentials.expires_in |
integer | Access token lifetime in seconds. Currently 3153600000 (100 years): AccessTokenService::DEFAULT_TTL_SECONDS |
credentials.scopes |
string[] | Scopes granted to this API key — every ApiKeyScope value (see module-8 §9.3 enums) |
owner.email |
string | Owner’s email address |
owner.name |
string | Owner’s display name |
linked |
boolean | false when a new business was created (HTTP 201); true when an existing business was reconnected (HTTP 200, message “Existing business linked successfully.”) |
password_setup.token |
string | One-time token for Set Password (POST /api/v1/partner/set-password) |
password_setup.expires_in |
integer | Seconds until the token expires (account_setup.partner_api_expiry_minutes × 60; default 900) |
Important Notes
- The
api_secretis returned only once in this response. It cannot be retrieved again. Store it securely. - The
access_tokenlifetime isexpires_inseconds (currently 100 years,AccessTokenService::DEFAULT_TTL_SECONDS). Use theapi_keyandapi_secretto issue new tokens via[POST /api/v1/auth/token](/docs/api/heldsway-rest-api-overview#token-issuance). - If the owner email already belongs to an existing HeldSway user, the business is created under that existing account.
action: "reconnect"reactivates the business owned by this email and keeps its data; with no such business it fails withNO_EXISTING_BUSINESS.action: "create_new"creates a fresh business when none exists; when one exists it wipes that business’s operational data and reactivates it (performOverrideReconnect()). Both reconnect paths return HTTP 200 withlinked: true.- When an active business owned by this email is already bound to the same
store_idand the request carries alocation_id, the location is added instead (HTTP 200, message “Location connected successfully.”). That response hasstatus: "location_connected",linked: true,business,location, andcredentialswith onlyaccess_token,token_type,expires_inandscopes.api_key/api_secretappear only when every earlier key had been revoked and a new one was minted. There is noownerand nopassword_setup.
Error Reference
| HTTP Status | Error Code | Cause |
|---|---|---|
Mapped by RegisterBusinessController and MapsPartnerErrors::mapCommonPartnerError(). |
| HTTP Status | Error Code | Cause |
|---|---|---|
400 |
DECRYPTION_FAILED |
Bad base64 in payload or iv, wrong IV length, or MAC mismatch |
401 |
UNAUTHORIZED |
Missing or invalid Authorization: Bearer header |
404 |
NO_EXISTING_BUSINESS |
action: "reconnect" but no business is owned by this email |
409 |
ALREADY_CONNECTED |
The email’s active business was connected by a different partner, or its connected email differs; details.existing_business describes it |
409 |
PARTNER_MISMATCH |
The email’s (inactive or deleted) business was connected by a different partner |
409 |
EMAIL_MISMATCH |
The email’s (inactive or deleted) business was connected with a different email |
409 |
BUSINESS_LOCKED |
The email’s business is suspended |
409 |
STORE_MISMATCH |
The email’s business is bound to a different store_id |
409 |
STORE_ALREADY_CONNECTED |
The store_id is already bound to another business (unique index businesses_partner_store_unique) |
409 |
ACCOUNT_DEACTIVATED |
The HeldSway user with this email has been deactivated |
409 |
BUSINESS_NAME_TAKEN |
Creating the business failed because its generated slug is still taken after 3 attempts; change business_name and retry |
422 |
CONSENT_REQUIRED |
Consent not given. RegisterBusinessRequest already rejects consent_given: false (accepted), so this service-level check is a backstop |
422 |
VALIDATION_ERROR |
Missing or invalid fields in the decrypted payload; message “Invalid business data in encrypted payload.”, per-field messages in details |
422 |
— | Envelope fields rejected by RegisterBusinessRequest: payload, iv, mac (required, mac exactly 64 characters), action (required, reconnect or create_new), consent_given (required, boolean, accepted), store_id / location_id (nullable string, max 64), location_name (nullable string, max 255). Laravel’s standard validation body (message + errors), no code |
429 |
— | Rate limit exceeded (10 requests/minute). Laravel’s throttle response: {"message": "Too Many Attempts."} with a Retry-After header |
500 |
REGISTRATION_FAILED |
Unexpected error that is not a RuntimeException |
503 |
PARTNER_DISABLED |
Partner integration is disabled |
503 |
PARTNER_NOT_CONFIGURED |
Partner token is not set on the server |
A RuntimeException the mapper does not recognise is re-thrown and returns Laravel’s generic 500 body instead of REGISTRATION_FAILED.
Error Response Examples
Invalid credentials (401)
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid partner credentials."
}
}
Decryption failed (400)
{
"success": false,
"error": {
"code": "DECRYPTION_FAILED",
"message": "Failed to decrypt the request payload. Verify the encryption parameters."
}
}
Business name taken (409)
{
"success": false,
"error": {
"code": "BUSINESS_NAME_TAKEN",
"message": "Business name is already taken. Update business name and try again."
}
}
Validation error (422)
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid business data in encrypted payload.",
"details": {
"business_name": ["The business name field is required."],
"email": ["The email field must be a valid email address."]
}
}
}
Partner disabled (503)
{
"success": false,
"error": {
"code": "PARTNER_DISABLED",
"message": "Partner integration is currently disabled."
}
}
Subdomain & Business URL
When a new business is created, its subdomain slug is generated from the business name by PartnerRegistrationService::generateAvailableSlug():
| Business Name | Generated Slug | Business URL |
|---|---|---|
| Acme Rentals | acme-rentals |
https://acme-rentals.<MAIN_DOMAIN> |
| My Store | my-store |
https://my-store.<MAIN_DOMAIN> |
| Acme Rentals (slug taken) | acme-rentals-k3x9qa (random) |
https://acme-rentals-k3x9qa.<MAIN_DOMAIN> |
Slug rules:
- Base =
Str::slug(business_name), cut to 56 characters, with leading/trailing hyphens trimmed - The base is used as-is when it is not empty, not a reserved subdomain (
config('domains.reserved_subdomains'):app,api,accounts,docs,www,mail,admin), and not taken by any business, including soft-deleted ones - Otherwise a hyphen and 6 random lowercase letters/digits are appended, trying up to 3 suffixes
- If the insert still hits the slug unique index, the transaction is retried up to 3 times; after that the request fails with
BUSINESS_NAME_TAKEN - The business URL uses the scheme of
APP_URLand the verified custom domain if one exists (Business::getUrl())
Using the Returned Credentials
After registration, use the credentials to interact with the HeldSway API:
1. Immediate use with access_token
The access_token returned in the registration response is valid for expires_in seconds:
curl https://api.heldsway.com/api/v1/affiliates \
-H "Authorization: Bearer <access_token>"
2. Long-term use with api_key + api_secret
After the access token expires, issue new tokens using the API key and secret:
# Step 1: Get a new access token
curl -X POST https://api.heldsway.com/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{
"api_key": "<api_key>",
"api_secret": "<api_secret>"
}'
# Step 2: Use the new token
curl https://api.heldsway.com/api/v1/affiliates \
-H "Authorization: Bearer <new_access_token>"
See Token Issuance for full token issuance details.
Set Password
Set the business owner’s password inline, using the one-time setup token from Register Business (source: App\Http\Controllers\Api\V1\Partner\SetPasswordController → PartnerRegistrationService::setPassword() → AccountSetupService::consume()). The password travels inside the encrypted envelope; the setup token travels in plain JSON.
POST /api/v1/partner/set-password
Authorization: Bearer <PARTNER_TOKEN>
Content-Type: application/json
Auth: partner.auth:rentmy (partner Bearer token, no scope) Rate limit: 10 requests/minute per IP (throttle:10,1)
Request Body
Rules from SetPasswordRequest::rules().
| Field | Type | Required | Description |
|---|---|---|---|
payload |
string | Yes | Base64-encoded AES-256-CBC ciphertext of the password JSON (see Encryption Specification) |
iv |
string | Yes | Base64-encoded 16-byte initialization vector |
mac |
string | Yes | 64-character hex HMAC-SHA256 digest |
setup_token |
string | Yes | password_setup.token from the Register Business response |
Decrypted Payload Fields
| Field | Type | Required | Constraints |
|---|---|---|---|
password |
string | Yes | Password::defaults(): at least 8 characters; in production also mixed case, letters, numbers and symbols (AppServiceProvider::boot()) |
password_confirmation |
string | Yes | Must equal password |
Behaviour
- The setup token is valid only while it is unconsumed and unexpired (
password_setup.expires_inseconds after it was issued;account_setup.partner_api_expiry_minutes, default 15 minutes) and only for the partner that connected the business. - On success the owner’s password is replaced, their email is marked verified if it was not, the token is consumed, and every other pending setup token for the same owner and business (for example an emailed setup link) is consumed too.
Success Response — 200 OK
{
"success": true,
"message": "Password set successfully.",
"data": { "email": "john@acme.com" }
}
Error Reference
| HTTP Status | Error Code | Cause |
|---|---|---|
400 |
DECRYPTION_FAILED |
Bad base64 in payload or iv, wrong IV length, or MAC mismatch |
401 |
UNAUTHORIZED |
Missing or invalid partner Bearer token |
409 |
ACCOUNT_DEACTIVATED |
The owner account behind the token is missing or inactive |
422 |
INVALID_SETUP_TOKEN |
Token unknown, expired, already used, or issued for a business connected by another partner. One message for every case: “This setup link is invalid, expired, or already used.” |
422 |
VALIDATION_ERROR |
The decrypted password fails the rules; message “Invalid password in encrypted payload.”, per-field messages in details |
422 |
— | Missing or malformed envelope fields or setup_token, rejected by SetPasswordRequest with Laravel’s standard validation body (message + errors), no code |
429 |
— | More than 10 requests/minute from this IP. Laravel’s throttle response: {"message": "Too Many Attempts."} with a Retry-After header |
500 |
SET_PASSWORD_FAILED |
Unexpected error that is not a RuntimeException |
503 |
PARTNER_DISABLED |
Partner integration is disabled |
503 |
PARTNER_NOT_CONFIGURED |
Partner token is not set on the server |
A RuntimeException the mapper does not recognise is re-thrown and returns Laravel’s generic 500 body instead of SET_PASSWORD_FAILED.
Rate Limiting
All three partner endpoints (check-business, register-business, set-password) are throttled at 10 requests per minute per IP address. Exceeding the limit returns HTTP 429 Too Many Requests with a Retry-After header indicating when you can retry.
Business Connection API
Check your business’s connection status and programmatically disconnect from the platform. These endpoints operate on the business associated with the authenticated API token — no business ID parameter is needed.
Authentication
Requires a Bearer access token with the appropriate scope:
| Scope | Grants Access To |
|---|---|
connections:read |
Retrieve connection status and business details |
connections:write |
Disconnect the business from the platform |
Authorization: Bearer <access_token>
To obtain a token, see Token Issuance.
Rate Limits
| Scope | Limit |
|---|---|
connections:read |
120 requests/minute |
connections:write |
60 requests/minute |
Data Models
Connection Status Object
The object returned by GET /api/v1/connection/status:
{
"connected": true,
"business_details": {
"name": "Acme Corp",
"slug": "acme",
"website": "https://acme.com",
"subdomain_url": "http://acme.heldsway.com",
"logo_url": "https://cdn.example.com/logo.png",
"commission_rate": 15
},
"subscription": {
"status": "active",
"plan_name": "Growth",
"interval": "month",
"price_cents": 4900,
"trial": null,
"current_period_end": "2026-11-15T00:00:00.000000Z",
"auto_renew": true,
"payment_method_added": true,
"payment_method": { "brand": "visa", "last4": "4242" },
"recommended_plan": null
}
}
Connection Status Field Reference
| Field | Type | Description |
|---|---|---|
connected |
boolean |
true when the business is active on the platform. Always true for a valid token (inactive businesses are rejected at the authentication layer). |
Business Details Field Reference
| Field | Type | Description |
|---|---|---|
name |
string |
The registered business name |
slug |
string |
Unique URL-safe identifier used in the subdomain ({slug}.heldsway.com) |
website |
string|null |
The business’s website URL. null if not configured. |
subdomain_url |
string |
Full URL of the business’s panel. Uses the verified custom domain if available, otherwise falls back to the subdomain pattern. |
logo_url |
string|null |
URL for the business logo (from branding settings). null if no logo is configured. |
commission_rate |
number|null |
The business’s default commission rate (%). null if not configured (uses platform default). Per-affiliate overrides take priority. |
Get Connection Status
Retrieve the connection status and profile details for the business associated with the current API token.
GET /api/v1/connection/status
Scope: connections:read
Example request:
curl https://api.heldsway.com/api/v1/connection/status \
-H "Authorization: Bearer <token>"
Response — 200 OK
{
"success": true,
"message": "OK",
"data": {
"connected": true,
"business_details": {
"name": "Acme Corp",
"slug": "acme",
"website": "https://acme.com",
"subdomain_url": "http://acme.heldsway.com",
"logo_url": "https://cdn.example.com/logo.png",
"commission_rate": 15
}
}
}
Response — 200 OK (no optional fields configured)
{
"success": true,
"message": "OK",
"data": {
"connected": true,
"business_details": {
"name": "Acme Corp",
"slug": "acme",
"website": null,
"subdomain_url": "http://acme.heldsway.com",
"logo_url": null,
"commission_rate": null
}
}
}
Note: This endpoint will always return
connected: truefor a valid token. If the business has been disconnected (inactive), the authentication middleware rejects the request before it reaches this endpoint — you will receive a403 Forbiddenresponse instead.
Disconnect Business
Deactivate the business on the platform. This is a destructive, one-way operation from the API perspective.
POST /api/v1/connection/disconnect
Content-Type: application/json
Scope: connections:write
Body Parameters — All fields are optional.
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
reason |
string|null |
No | Max 500 characters | Optional reason for disconnecting. Recorded in the audit log for administrative reference. |
Example request:
curl -X POST https://api.heldsway.com/api/v1/connection/disconnect \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "reason": "Migrating to a different platform" }'
Response — 200 OK
{
"success": true,
"message": "Business disconnected successfully."
}
Example — Disconnect without a reason:
curl -X POST https://api.heldsway.com/api/v1/connection/disconnect \
-H "Authorization: Bearer <token>"
Response — 200 OK
{
"success": true,
"message": "Business disconnected successfully."
}
Error — 422 Unprocessable Entity (reason too long — Laravel’s standard validation body from DisconnectBusinessApiRequest)
{
"message": "The reason field must not be greater than 500 characters.",
"errors": {
"reason": ["The reason field must not be greater than 500 characters."]
}
}
Side Effects
After a successful disconnect:
| Effect | Detail |
|---|---|
| Business deactivated | businesses.is_active is set to false |
| All API tokens invalidated | The authentication middleware rejects tokens for inactive businesses. All existing tokens for this business immediately stop working. |
| Audit log entry created | A status_changed entry is written to activity_logs with the disconnect reason (if provided), business ID, and timestamp. |
| Subdomain access blocked | The business’s subdomain panel ({slug}.heldsway.com) becomes inaccessible. |
Reconnection
Reconnection is not available via the API. Only a platform super admin can reactivate a disconnected business from the main admin panel. Contact your platform administrator to restore access.
Idempotency
Once disconnected, subsequent calls to this endpoint will fail with 403 Forbidden because the token is no longer valid for an inactive business. The endpoint does not need explicit idempotency handling — the authentication layer enforces it naturally.
Error Reference
All endpoints follow the standard response envelope.
| HTTP Status | Error Code | Cause |
|---|---|---|
401 |
UNAUTHORIZED |
Missing or invalid Bearer token |
402 |
SUBSCRIPTION_REQUIRED |
POST /api/v1/connection/disconnect only — the business has no active subscription (see the REST API overview, Error Reference → Subscription Required (402)). GET /api/v1/connection/status is not gated |
403 |
FORBIDDEN |
Token does not have the required scope (connections:read or connections:write), or the business is already disconnected (inactive) |
422 |
— | Invalid request body. DisconnectBusinessApiRequest rejects it with Laravel’s standard validation body (message + errors), no code |
429 |
RATE_LIMIT_EXCEEDED |
Rate limit exceeded |
500 |
SERVER_ERROR |
Unexpected server error |
Quick Reference
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/v1/connection/status |
connections:read |
Get connection status and business details |
POST |
/api/v1/connection/disconnect |
connections:write |
Disconnect business from the platform |