Licence Transfers

8 views Markdown

The six endpoints that move a service licence to another client: starting, following, chasing and cancelling.

Overview

A licence transfer moves a service's licence to another client. These six endpoints manage the transfer record: starting it, following it, chasing it and calling it off.

The flow does not finish in one step. Starting only opens the record; both sides confirm by e-mail, the fee invoice is paid if there is one, and only then does the licence change hands. What the transfer is waiting on right now is in the confirmation state on the detail.

This is not a service ownership handover. Changing who owns a service is a separate system with its own endpoints.

Reference

Listing the Transfers

get/api/v1/admin/services/{id}/license-transfers
Services/GetServiceLicenseTransfers admin

Returns the service's transfer history, newest first.

Response fields data[] — 17
idintId of the transfer.
service_idintId of the service being transferred.
product_idintId of the product.
transferor_idintId of the client handing over.
transferee_idintId of the client taking on.
statusstringThe transfer status. It differs while verification is pending, once complete, and after cancellation.
feeobjectThe transfer fee.
typestringpercentage is a share, fixed a set amount.
amountfloatThe value of the fee.
currency_idintCurrency id.
invoice_idintId of the fee invoice. Zero when none was produced.
invoice_recipientstringWho the invoice is raised against: the one handing over or the one taking on.
notify_partiesboolWhether the parties are being notified.
notesstringAn admin note.
expires_atstringWhen the transfer lapses. If both sides have not confirmed by then, it falls away.
initiated_atstringWhen it was started.
completed_atstring | nullWhen it completed.
cancelled_atstring | nullWhen it was cancelled.
cancel_reasonstring | nullWhy it was cancelled.
created_atstringWhen the record was created.
Errors 2
not_found404No such service.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/services/506/license-transfers' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The list does NOT carry the confirmation state; read one transfer's detail to see it.
$transfers = Api::Services()->GetServiceLicenseTransfers(['id' => 506])['data'];

Starting a Transfer

post/api/v1/admin/services/{id}/license-transfers
Services/CreateServiceLicenseTransfer admin 201

Opens a transfer, sends a verification to both sides and raises the fee invoice if there is one.

Body 3
transferee_idintrequiredId of the client taking the licence on.
notesstringAn admin note.
notify_partiesboolNotifies the parties. On by default.
Response fields data — 18
idintId of the transfer.
service_idintId of the service being transferred.
product_idintId of the product.
transferor_idintId of the client handing over.
transferee_idintId of the client taking on.
statusstringThe transfer status. It differs while verification is pending, once complete, and after cancellation.
feeobjectThe transfer fee.
typestringpercentage is a share, fixed a set amount.
amountfloatThe value of the fee.
currency_idintCurrency id.
invoice_idintId of the fee invoice. Zero when none was produced.
invoice_recipientstringWho the invoice is raised against: the one handing over or the one taking on.
notify_partiesboolWhether the parties are being notified.
notesstringAn admin note.
expires_atstringWhen the transfer lapses. If both sides have not confirmed by then, it falls away.
initiated_atstringWhen it was started.
completed_atstring | nullWhen it completed.
cancelled_atstring | nullWhen it was cancelled.
cancel_reasonstring | nullWhy it was cancelled.
created_atstringWhen the record was created.
verificationsobject[]The confirmation state of both sides.
partystringWhich side: transferor hands over, transferee takes on.
emailstringWhere the verification was sent.
verifiedboolWhether this side confirmed.
verified_atstring | nullWhen they confirmed.
sent_atstringWhen it was first sent.
last_resent_atstring | nullWhen it was last resent.
resend_countintHow many times it was resent.
Errors 4
not_found404No such service.
transferee_required422No receiving client was given.
transfer_not_allowed422The transfer cannot start. The licence transfer addon may not be installed, transfers may be off on the product, the service may already have an open transfer, or it may not meet the eligibility rules.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/services/506/license-transfers' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"transferee_id":88,"notes":"sold","notify_parties":true}'
const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    transferee_id: 88,
    notes: 'sold',
    notify_parties: true,
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'transferee_id' => 88,
        'notes'         => 'sold',
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Starting does NOT complete it: the licence stays put until both sides confirm by e-mail.
$transfer = Api::Services()->CreateServiceLicenseTransfer([
    'id'            => 506,
    'transferee_id' => 88,
])['data'];

$waiting = array_filter($transfer['verifications'], fn (array $v): bool => !$v['verified']);
Response
{
  "data": {
    "id": 7,
    "service_id": 506,
    "transferor_id": 50,
    "transferee_id": 88,
    "status": "pending_verification",
    "fee": { "type": "percentage", "amount": 5.0, "currency_id": 840 },
    "invoice_id": 0,
    "invoice_recipient": "transferee",
    "expires_at": "2026-06-28 12:00:00",
    "verifications": [
      {
        "party": "transferor",
        "email": "[email protected]",
        "verified": false,
        "verified_at": null,
        "sent_at": "2026-06-21 12:00:00",
        "last_resent_at": null,
        "resend_count": 0
      },
      {
        "party": "transferee",
        "email": "[email protected]",
        "verified": false,
        "verified_at": null,
        "sent_at": "2026-06-21 12:00:00",
        "last_resent_at": null,
        "resend_count": 0
      }
    ]
  }
}
{
  "error": {
    "code": "transfer_not_allowed",
    "message": "License transfer is not enabled for this product."
  }
}

Transfer Detail

get/api/v1/admin/services/{id}/license-transfers/{tid}
Services/GetServiceLicenseTransfer admin confirmations included

Returns one transfer together with the confirmation state of both sides.

Response fields data — 18
idintId of the transfer.
service_idintId of the service being transferred.
product_idintId of the product.
transferor_idintId of the client handing over.
transferee_idintId of the client taking on.
statusstringThe transfer status. It differs while verification is pending, once complete, and after cancellation.
feeobjectThe transfer fee.
typestringpercentage is a share, fixed a set amount.
amountfloatThe value of the fee.
currency_idintCurrency id.
invoice_idintId of the fee invoice. Zero when none was produced.
invoice_recipientstringWho the invoice is raised against: the one handing over or the one taking on.
notify_partiesboolWhether the parties are being notified.
notesstringAn admin note.
expires_atstringWhen the transfer lapses. If both sides have not confirmed by then, it falls away.
initiated_atstringWhen it was started.
completed_atstring | nullWhen it completed.
cancelled_atstring | nullWhen it was cancelled.
cancel_reasonstring | nullWhy it was cancelled.
created_atstringWhen the record was created.
verificationsobject[]The confirmation state of both sides.
partystringWhich side: transferor hands over, transferee takes on.
emailstringWhere the verification was sent.
verifiedboolWhether this side confirmed.
verified_atstring | nullWhen they confirmed.
sent_atstringWhen it was first sent.
last_resent_atstring | nullWhen it was last resent.
resend_countintHow many times it was resent.
Errors 2
not_found404No such service or transfer.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/services/506/license-transfers/7' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// This is where you see what the transfer is waiting on: a confirmation or a payment.
$transfer = Api::Services()->GetServiceLicenseTransfer(['id' => 506, 'tid' => 7])['data'];

$allConfirmed = !array_filter($transfer['verifications'], fn (array $v): bool => !$v['verified']);
$feeUnpaid    = $transfer['invoice_id'] > 0;

Cancelling a Transfer

delete/api/v1/admin/services/{id}/license-transfers/{tid}
Services/CancelServiceLicenseTransfer admin the invoice is cancelled too

Stops a transfer in progress and cancels the unpaid fee invoice behind it.

Body 1
notify_partiesboolNotifies the parties. On by default.
Response fields data — 2
cancelledboolWhether it was cancelled.
idintId of the transfer.
Errors 4
not_found404No such service or transfer.
not_active422The transfer is not in a state that can be cancelled. It may already be complete or cancelled.
cancel_failed500The transfer could not be cancelled.
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/services/506/license-transfers/7' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"notify_parties":true}'
const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ notify_parties: true }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['notify_parties' => true]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// A completed transfer CANNOT be cancelled: taking ownership back needs a new transfer the other way.
$response = Api::Services()->CancelServiceLicenseTransfer(['id' => 506, 'tid' => 7]);

Resending a Verification

post/api/v1/admin/services/{id}/license-transfers/{tid}/resend
Services/ResendServiceLicenseTransferVerification admin

Sends the verification e-mail again to a side that has not confirmed.

Body 1
partystringWhich side: transferor, transferee or both. It defaults to both.
Response fields data — 2
resentarrayThe sides that actually got one. A side that already confirmed is not listed.
idintId of the transfer.
Errors 4
not_found404No such service or transfer.
invalid_party422The side is not one of the three values.
resend_failed422There is nothing to resend. Both sides have confirmed, or the transfer is past the verification stage.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/services/506/license-transfers/7/resend' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"party":"both"}'
const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/resend', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ party: 'both' }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/resend');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['party' => 'both']),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Asking for both is harmless: a side that confirmed is skipped, and the list says who got one.
$response = Api::Services()->ResendServiceLicenseTransferVerification([
    'id'    => 506,
    'tid'   => 7,
    'party' => 'both',
]);

$sentTo = $response['data']['resent'];

Reminding About the Fee Invoice

post/api/v1/admin/services/{id}/license-transfers/{tid}/remind-invoice
Services/RemindServiceLicenseTransferInvoice admin

Sends a reminder for the transfer fee invoice.

Body
No body is needed, send an empty one. The service and the transfer come from the path, and the recipient is whoever the transfer's invoice recipient names; there is no field to send it elsewhere.
Response fields data — 3
remindedboolWhether the reminder went out.
idintId of the transfer.
invoice_idintId of the invoice reminded about.
Errors 3
not_found404No such service or transfer.
invoice_not_generated422No fee invoice was produced for this transfer. A transfer with no fee never gets one.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/services/506/license-transfers/7/remind-invoice' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/remind-invoice', {
  method: 'POST',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/remind-invoice');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The reminder goes to whoever the invoice is RAISED AGAINST, which can be either side.
$transfer = Api::Services()->GetServiceLicenseTransfer(['id' => 506, 'tid' => 7])['data'];

if ($transfer['invoice_id'] > 0) {
    Api::Services()->RemindServiceLicenseTransferInvoice(['id' => 506, 'tid' => 7]);
}

Pitfalls

Starting does not complete the transfer

Even when the start request answers 201, the licence is still with its old owner. The transfer only moves once both sides confirm by e-mail and the fee invoice, if any, is paid. The confirmation list on the response says who is being waited on.

A transfer has a deadline

The record carries an expiry. If the confirmations are not in by then the transfer lapses on its own and the licence never moves. On one that has been waiting a while, check the deadline before cancelling: it may have lapsed already.

Four different things can refuse the start

transfer_not_allowed is not one rule but four conditions at once: the licence transfer addon may not be installed, transfers may be off on the product, the service may already have an open transfer, or it may not meet the eligibility rules. The message says which one; reading the code alone is misleading.

Do not confuse it with an ownership handover

The transfer here moves the licence and has its own verification and fee flow. Changing who owns a service, so that its invoices start going to another client, is an entirely different system living on its own endpoints.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.