KDPika Licensing Sign inStart free
API v1
Show all languages
Browse documentation

Choose a page in the navigation drawer.

KDPika Licensing · API v1 Intermediate

Issue and deliver licenses manually

Issue dashboard licenses, run a safe CSV API batch, find customers and deliver saved keys without inventing import or key-recovery features.

On this page
Updated 2026-10-18 UTCDownload Markdown

Issue a license after your own purchase or support checks, then deliver its saved key through a private channel.

What you will learn

  • Create one license from your workspace.
  • Run the complete CSV batch tool with durable duplicate prevention and encrypted key storage.
  • Look up customers and handle a lost key without claiming an unsupported recovery API.

Prerequisites

  • Complete the Sandbox quickstart.
  • Create an active product and product plan before issuing a license.
  • For the batch tool, use PHP 8.2+ with cURL, PDO SQLite and Sodium.
  • Keep all customer files, keys and the issuance state outside your web root.
  • Confirm the buyer/order independently; this tool does not verify a store payment.

Create one license in the dashboard

  1. Select the intended environment, starting with Sandbox.
  2. Open Licenses.
  3. Select Create license or Create first license.
  4. Select the product and plan in Product and plan.
  5. Enter Customer email and, if needed, Customer name, Company and External customer ID.
  6. Select Create license.
  7. Copy the complete one-time key immediately into your private delivery workflow.
  8. Confirm the row's customer, product, plan, expiration and environment.

Expected result: a license and, when necessary, its customer record are created in that environment. The complete key appears once in the success panel. Later rows show only a mask. Creating a license does not charge a customer or automatically email its complete key. A product plan's displayed price is not payment proof.

The ordinary dashboard form is not a transactional store-fulfillment queue. Repeating its submit action after an uncertain result can create another license. Inspect existing customer/license rows before issuing again.

Batch operations that are supported

There is no native dashboard CSV importer, bulk-issue endpoint, public customer-list API or public customer-email search API in v1. The downloadable tool below calls the real create license operation once per validated CSV row. It does not change the product database schema.

The tool keeps its own private SQLite ledger. It serializes workers with an operating-system file lock, records IN_FLIGHT before a network attempt, and records DONE only after an explicit 201 with the expected environment. The returned key is encrypted using Sodium secretbox before completion is reported. The encryption key stays in your secret store, separate from the ledger and backups.

An unknown result becomes REVIEW. An interrupted IN_FLIGHT row is also not retried automatically. This is deliberate: the current API replay implementation has a 24-hour retention window and known expiration/concurrency limits. Neither this tool nor a provider event ID can turn it into exactly-once delivery. Do not delete the ledger or invent another order ID to bypass a review.

Prepare the CSV

  1. Download and extract the manual issuance source archive.
  2. Create a private UTF-8 CSV with the exact header order below, without a byte-order mark.
  3. Use one stable order-line ID per row and one product per batch.
  4. Replace the example product and plan references with your saved dashboard IDs or slugs.
  5. Use the buyer email you verified in your own store/support workflow.
orders.csvtext
order_id,product_id,plan_id,email,name
YOUR_ORDER_LINE_001,YOUR_PRODUCT_ID,YOUR_PLAN_ID,buyer-one@example.invalid,Demo Buyer One
YOUR_ORDER_LINE_002,YOUR_PRODUCT_ID,YOUR_PLAN_ID,buyer-two@example.invalid,Demo Buyer Two
ColumnRequiredMeaning
order_idyesStable order-line reference; unique within your workspace/store namespace, not a new random retry ID
product_idyesProduct ID or slug matching the API key's product
plan_idyesActive product plan ID or slug within that product
emailyesBuyer email used for the license's customer record
namecolumn required, value optionalCustomer display name

The sample accepts at most 50 rows and a single product per batch. Those are local tool safeguards, not a new platform plan allowance. It validates the whole CSV before sending any issuance request. Use different stable order-line IDs for different purchased items; do not reuse an order ID across several rows or add a customer email to an idempotency key.

Product and plan slugs are chosen when you create them. A public App ID is not the create endpoint's product reference. There is no public API for discovering all your product/plan IDs; save the mapping when configuring your integration.

Validate without issuing

bashbash
php bulk-issue.php dry-run orders.csv

Expected output for the supplied file: {"valid_rows":2,"network_requests":0,"state_writes":0}. This verifies CSV structure, not whether your placeholder product/plan exists or whether payment succeeded. Replace every example value before applying a real batch.

Configure private issuance state

Environment variableValue and purpose
KDP_API_KEYYour product-scoped backend developer API key with licenses:write; never ship it in the buyer application
KDP_ENVIRONMENTsandbox initially; must match the key prefix and actual response environment
KDP_BASE_URLhttps://licensing.kdpika.com, without /api/v1, query or fragment
KDP_BATCH_NAMESPACEStable nonpersonal identifier for one organization/store, retained across key rotation and reruns
KDP_BATCH_STATEAbsolute private owner-only directory outside all web roots
KDP_OUTBOX_KEYBase64-encoded 32 random bytes supplied by your secret store; back it up separately
KDP_CONFIRM_LIVEMust be 1 in addition to an explicitly configured live environment/key; never enable for a test

This POSIX CLI tool is executed on macOS/PHP with SQLite and Sodium. Its absolute-path and permission checks are not a native Windows port. On Windows, use a reviewed WSL/POSIX deployment or adapt and test the path/owner-only ACL checks before running it. No native Windows execution is claimed.

The local test suite permits HTTP only when KDP_ALLOW_LOOPBACK=1, the environment is sandbox and the literal host is 127.0.0.1. This is a test-only exception, not a recommendation to send production credentials over HTTP. No unverified redirect is followed. Keep all secret environment values out of shell history and screenshots.

Apply and read the result

bashbash
php bulk-issue.php apply orders.csv

Expected result: one safe JSON status per processed row, identified by a nonpersonal order hash. DONE includes a license ID and request_id but never the complete key. An unchanged DONE row is skipped on rerun, even beyond the API's replay window. The script waits 1.1 seconds after a successful new create; other traffic can still consume the shared rate bucket, so this is not a rate-limit exemption.

StateMeaningNext action
DONEExplicit successful creation and encrypted key savedDeliver the saved key; do not issue again
IN_FLIGHTIntent saved, completion unknown after an interruptionStop and reconcile before any further write
REVIEWHTTP, transport, response or environment result was not an accepted 201Review safe request_id/error code and dashboard records
CONFLICTThe stable order reference was reused with different request dataCorrect your mapping; do not bypass the record with a new ID
BATCH_BUSYAnother worker holds the private ledger lockWait for that worker; do not run a second state directory

A review or conflict stops the current batch with exit 2; local input/configuration failures exit 1. A complete successful batch exits 0. Even explicit API rejections are held for operator review rather than automatically retried. Do not interpret REVIEW as proof that no license was created.

Export a saved key for delivery

  1. Verify the requester/order and recipient before exporting.
  2. Run export with the same environment, namespace, private ledger and outbox key.
  3. Send the saved key only through your reviewed private delivery mechanism.
  4. Remove the temporary plaintext delivery file when your workflow no longer needs it.
bashbash
php bulk-issue.php export YOUR_ORDER_LINE_001 /absolute/private/delivery.json

Expected result: an owner-only file containing the license ID, complete key and intended customer. Stdout reports exported=true and contains_secret=true without printing the key. Existing export files are not overwritten. Keep the file out of public downloads, analytics, support tickets and Git. A lost outbox encryption key cannot be reconstructed from the encrypted database.

Complete batch source

This exact source is syntax-checked and executed against an isolated Sandbox, including a gateway that loses a genuinely successful create response. The download manifest supplies archive and source hashes.

bulk-issue.phpphp
<?php
declare(strict_types=1);
// Backend-only CSV issuance with a durable no-blind-retry ledger and encrypted key outbox.
// PHP 8.2+, cURL, PDO SQLite and Sodium. No Composer dependencies.
// Run: php bulk-issue.php dry-run orders.csv; then configure the documented private environment.
// Apply: php bulk-issue.php apply orders.csv; expected safe JSON states, never a key on stdout.
// Export explicitly: php bulk-issue.php export YOUR_ORDER_LINE_ID /absolute/private/delivery.json
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}

function batchRows(string $file): array
{
    $stream=@fopen($file,'rb');if(!$stream)throw new RuntimeException('INPUT_UNAVAILABLE');
    try{
        $header=fgetcsv($stream,escape:'');
        if($header!==['order_id','product_id','plan_id','email','name'])throw new RuntimeException('INVALID_CSV_HEADER');
        $rows=[];$seen=[];$products=[];
        while(($values=fgetcsv($stream,escape:''))!==false){
            if($values===[null])continue;
            if(count($rows)>=50||count($values)!==5)throw new RuntimeException('INVALID_CSV_SIZE');
            $row=array_combine($header,array_map('trim',$values));
            if(!preg_match('/^[A-Za-z0-9_.:-]{1,160}$/D',$row['order_id'])||isset($seen[$row['order_id']])||
                strlen($row['product_id'])<2||strlen($row['product_id'])>190||strlen($row['plan_id'])<2||strlen($row['plan_id'])>140||
                !filter_var($row['email'],FILTER_VALIDATE_EMAIL)||strlen($row['email'])>190||strlen($row['name'])>160)throw new RuntimeException('INVALID_CSV_ROW');
            $seen[$row['order_id']]=true;$products[$row['product_id']]=true;$rows[]=$row;
        }
        if(!$rows||count($products)!==1)throw new RuntimeException('ONE_PRODUCT_PER_BATCH_REQUIRED');
        return $rows;
    }finally{fclose($stream);}
}

function batchEnvironment(): string
{
    $value=strtolower((string)getenv('KDP_ENVIRONMENT'));
    if(!in_array($value,['sandbox','live'],true))throw new RuntimeException('ENVIRONMENT_REQUIRED');
    if($value==='live'&&getenv('KDP_CONFIRM_LIVE')!=='1')throw new RuntimeException('LIVE_CONFIRMATION_REQUIRED');
    return $value;
}

function batchDirectory(string $path): string
{
    if(!str_starts_with($path,DIRECTORY_SEPARATOR)||is_link($path))throw new RuntimeException('PRIVATE_ABSOLUTE_DIRECTORY_REQUIRED');
    if(!is_dir($path)&&!@mkdir($path,0700,true))throw new RuntimeException('PRIVATE_DIRECTORY_UNAVAILABLE');
    if(PHP_OS_FAMILY!=='Windows'&&((int)fileperms($path)&0077)!==0)throw new RuntimeException('DIRECTORY_MUST_BE_OWNER_ONLY');
    return rtrim($path,DIRECTORY_SEPARATOR);
}

function batchKey(): string
{
    $key=base64_decode((string)getenv('KDP_OUTBOX_KEY'),true);
    if($key===false||strlen($key)!==SODIUM_CRYPTO_SECRETBOX_KEYBYTES)throw new RuntimeException('OUTBOX_KEY_REQUIRED');
    return $key;
}

function batchOrderKey(string $order,string $environment): string
{
    $namespace=(string)getenv('KDP_BATCH_NAMESPACE');
    if(!preg_match('/^[A-Za-z0-9_.:-]{1,100}$/D',$namespace))throw new RuntimeException('WORKSPACE_STORE_NAMESPACE_REQUIRED');
    return hash('sha256',$namespace."\0".$environment."\0".$order);
}

function batchDatabase(string $directory): PDO
{
    $path=$directory.'/issuance.sqlite';if(is_link($path))throw new RuntimeException('INVALID_STATE_PATH');
    $database=new PDO('sqlite:'.$path,null,null,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC]);
    chmod($path,0600);
    $database->exec('PRAGMA busy_timeout=3000');
    $database->exec("CREATE TABLE IF NOT EXISTS issuance(order_hash TEXT PRIMARY KEY,request_hash TEXT NOT NULL,request_json TEXT NOT NULL,state TEXT NOT NULL,license_id TEXT,secret_cipher TEXT,request_id TEXT,http_status INTEGER,error_code TEXT,created_at INTEGER NOT NULL)");
    return $database;
}

function batchConfiguration(string $environment): array
{
    $base=rtrim((string)(getenv('KDP_BASE_URL')?:'https://licensing.kdpika.com'),'/');
    $parts=parse_url($base);
    $local=getenv('KDP_ALLOW_LOOPBACK')==='1'&&$environment==='sandbox'&&($parts['scheme']??'')==='http'&&($parts['host']??'')==='127.0.0.1';
    if(!$parts||isset($parts['user'])||isset($parts['pass'])||isset($parts['query'])||isset($parts['fragment'])||!empty($parts['path'])||(!$local&&($parts['scheme']??'')!=='https'))throw new RuntimeException('INVALID_BASE_URL');
    $key=(string)getenv('KDP_API_KEY');
    if(!str_starts_with($key,'lmp_'.$environment.'_'))throw new RuntimeException('KEY_ENVIRONMENT_MISMATCH');
    return [$base,$key,$local];
}

function batchRequest(string $json,string $idempotency,string $environment): array
{
    [$base,$key,$local]=batchConfiguration($environment);
    $raw='';$curl=curl_init($base.'/api/v1/licenses');
    curl_setopt_array($curl,[CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>$json,CURLOPT_FOLLOWLOCATION=>false,CURLOPT_CONNECTTIMEOUT=>5,CURLOPT_TIMEOUT=>15,
        CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2,CURLOPT_PROTOCOLS=>$local?CURLPROTO_HTTP:CURLPROTO_HTTPS,
        CURLOPT_HTTPHEADER=>['Content-Type: application/json','Accept: application/json','Authorization: Bearer '.$key,'Idempotency-Key: '.$idempotency,'User-Agent: KDPika-Manual-Issuance/1'],
        CURLOPT_WRITEFUNCTION=>static function($handle,string $bytes)use(&$raw):int{if(strlen($raw)+strlen($bytes)>1048576)return 0;$raw.=$bytes;return strlen($bytes);}]);
    $sent=curl_exec($curl);$status=(int)curl_getinfo($curl,CURLINFO_RESPONSE_CODE);
    $body=json_decode($raw,true);
    return [$sent!==false,$status,is_array($body)?$body:[]];
}

function batchApply(array $rows,PDO $database,string $environment,string $outboxKey): int
{
    foreach($rows as $row){
        $orderHash=batchOrderKey($row['order_id'],$environment);
        $json=json_encode(['product_id'=>$row['product_id'],'plan_id'=>$row['plan_id'],
            'customer'=>['email'=>$row['email'],'name'=>$row['name']]],JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);
        $requestHash=hash('sha256',$json);
        $find=$database->prepare('SELECT * FROM issuance WHERE order_hash=?');$find->execute([$orderHash]);$prior=$find->fetch();
        if($prior){
            $state=hash_equals($prior['request_hash'],$requestHash)?$prior['state']:'CONFLICT';
            echo json_encode(['order_hash'=>$orderHash,'state'=>$state,'new_request'=>false]).PHP_EOL;
            if($state!=='DONE')return 2;
            continue;
        }
        // Commit intent before any network attempt. A crash must never silently reset it.
        $insert=$database->prepare("INSERT INTO issuance(order_hash,request_hash,request_json,state,created_at) VALUES(?,?,?,'IN_FLIGHT',?)");
        $insert->execute([$orderHash,$requestHash,$json,time()]);
        try{[$transport,$status,$response]=batchRequest($json,'manual_'.$orderHash,$environment);}
        catch(Throwable){$transport=false;$status=0;$response=[];}
        $license=$response['license']??null;
        $valid=$transport&&$status===201&&($response['success']??null)===true&&is_array($license)&&
            is_string($license['id']??null)&&is_string($license['license_key']??null)&&strlen($license['license_key'])>=12&&strlen($license['license_key'])<=100&&($license['environment']??null)===$environment;
        $requestId=is_string($response['request_id']??null)?substr($response['request_id'],0,190):null;
        $code=is_string($response['error']['code']??null)&&preg_match('/^[A-Z_]{1,80}$/D',$response['error']['code'])?$response['error']['code']:null;
        if(!$valid){
            $database->prepare("UPDATE issuance SET state='REVIEW',http_status=?,request_id=?,error_code=? WHERE order_hash=?")->execute([$status,$requestId,$code,$orderHash]);
            echo json_encode(['order_hash'=>$orderHash,'state'=>'REVIEW','http_status'=>$status,'request_id'=>$requestId,'error_code'=>$code]).PHP_EOL;
            return 2;
        }
        $nonce=random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $cipher=base64_encode($nonce.sodium_crypto_secretbox($license['license_key'],$nonce,$outboxKey));
        // Save the returned key encrypted before reporting completion. If this write fails,
        // IN_FLIGHT remains and the next run refuses a second API call.
        $database->prepare("UPDATE issuance SET state='DONE',license_id=?,secret_cipher=?,request_id=?,http_status=201 WHERE order_hash=?")
            ->execute([$license['id'],$cipher,$requestId,$orderHash]);
        echo json_encode(['order_hash'=>$orderHash,'state'=>'DONE','license_id'=>$license['id'],'request_id'=>$requestId,'new_request'=>true]).PHP_EOL;
        usleep(1100000); // Below the documented 60 creates/minute per-key bucket, before other traffic.
    }
    return 0;
}

function batchExport(PDO $database,string $order,string $environment,string $key,string $target): void
{
    $find=$database->prepare("SELECT license_id,secret_cipher,request_json FROM issuance WHERE order_hash=? AND state='DONE'");
    $find->execute([batchOrderKey($order,$environment)]);$row=$find->fetch();if(!$row)throw new RuntimeException('NO_COMPLETED_DELIVERY');
    $encrypted=base64_decode($row['secret_cipher'],true);if($encrypted===false)throw new RuntimeException('INVALID_OUTBOX');
    $plain=sodium_crypto_secretbox_open(substr($encrypted,SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),substr($encrypted,0,SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),$key);
    if($plain===false)throw new RuntimeException('OUTBOX_DECRYPTION_FAILED');
    if(!str_starts_with($target,DIRECTORY_SEPARATOR))throw new RuntimeException('PRIVATE_EXPORT_PATH_REQUIRED');
    batchDirectory(dirname($target));$stream=@fopen($target,'xb');if(!$stream)throw new RuntimeException('EXPORT_EXISTS_OR_UNAVAILABLE');
    try{chmod($target,0600);$customer=json_decode($row['request_json'],true,flags:JSON_THROW_ON_ERROR)['customer'];
        $json=json_encode(['license_id'=>$row['license_id'],'license_key'=>$plain,'customer'=>$customer],JSON_THROW_ON_ERROR);
        if(fwrite($stream,$json)!==strlen($json))throw new RuntimeException('EXPORT_WRITE_FAILED');
    }finally{fclose($stream);sodium_memzero($plain);}
    echo '{"exported":true,"contains_secret":true}'.PHP_EOL;
}

try{
    if(($argc!==3||!in_array($argv[1],['dry-run','apply'],true))&&($argc!==4||$argv[1]!=='export'))throw new RuntimeException('USAGE_DRY_RUN_APPLY_OR_EXPORT');
    if($argv[1]==='dry-run'){$rows=batchRows($argv[2]);echo json_encode(['valid_rows'=>count($rows),'network_requests'=>0,'state_writes'=>0]).PHP_EOL;exit;}
    umask(0077);$environment=batchEnvironment();$key=batchKey();
    $directory=batchDirectory((string)getenv('KDP_BATCH_STATE'));
    $lock=@fopen($directory.'/worker.lock','c');if(!$lock||!flock($lock,LOCK_EX|LOCK_NB))throw new RuntimeException('BATCH_BUSY');
    try{
        $database=batchDatabase($directory);
        if($argv[1]==='export'){batchExport($database,$argv[2],$environment,$key,$argv[3]);$exit=0;}
        else{$rows=batchRows($argv[2]);batchConfiguration($environment);$exit=batchApply($rows,$database,$environment,$key);}
    }finally{flock($lock,LOCK_UN);fclose($lock);sodium_memzero($key);}
    exit($exit);
}catch(Throwable $error){
    // No raw exception, file path, key, response body or customer data in diagnostics.
    $code=preg_match('/^[A-Z_]+$/D',$error->getMessage())?$error->getMessage():'LOCAL_OPERATION_FAILED';
    echo json_encode(['completed'=>false,'error'=>$code]).PHP_EOL;exit(1);
}

Find a customer and help them

  1. Select the correct environment and open Customers.
  2. Locate the verified email in the displayed directory, using your browser's find feature if needed.
  3. Check External ID, license count and the corresponding Licenses rows against your own order mapping.
  4. If the license ID is known, use get license from your authenticated backend for its scoped current summary.

Expected result: you find the intended customer without an invented public email-search endpoint. The v1 GET operation requires a known license ID and does not return the complete key.

Customers also provides Create link with a selected duration and an optional Email checkbox. The link allows the customer to inspect their licenses/devices and release devices; it is not a full-key recovery link or a store-billing cancellation portal. Creating another link revokes the previous active customer link. Protect these bearer links like credentials and verify the recipient before generating one.

Resend or replace a key

If your private outbox has the original key, verify the customer's identity and resend that same saved key through your authorized delivery workflow. Do not call create again just to resend an email. Neither the dashboard mask, customer portal nor license GET response can reconstruct a lost plaintext key.

If no authorized copy exists, investigate the original issuance outcome before deciding on a replacement. Replacing access is an explicit support operation: verify the buyer, document the mapping, issue a replacement only when authorized, deliver it privately and revoke the old license when appropriate. Revocation is terminal in the current lifecycle, so do not revoke a working license before planning the handover. The server's sensitive 24-hour idempotency response retention is not a public long-term key vault.

Expected result

Each approved order line has one durable issuance record and one privately delivered key. Reruns reuse your ledger, conflicting inputs stop and uncertain downstream results are investigated instead of blindly retried.

Common mistakes

  • Importing the example placeholders without replacing product/plan references.
  • Deleting the ledger or changing namespace to force a duplicate batch through.
  • Treating an HTTP timeout or REVIEW state as proof that nothing was created.
  • Emailing a key mask, storing an outbox key beside the encrypted database or printing complete keys in logs.

Security notes

Manual issuance does not confirm a payment, refund or subscription. Keep provider secrets and developer API keys only on your backend. Encrypt key delivery state, restrict filesystem permissions and backups, keep an access-controlled audit record, and retain customer data only as required. A CSV is personal data even when its exported keys are encrypted separately.

Troubleshooting

SymptomCauseFix
INVALID_CSV_HEADER or INVALID_CSV_ROWWrong header, encoding, repeated order ID or invalid valueCorrect the whole CSV and rerun dry-run before applying
DIRECTORY_MUST_BE_OWNER_ONLYPrivate directory permits other Unix usersSecure a dedicated private directory; do not change a shared web root's permissions
KEY_ENVIRONMENT_MISMATCHConfigured key prefix differs from selected environmentUse a properly scoped key from the intended environment
REVIEW with PLAN_NOT_FOUNDKey/product/plan mapping is invalidCorrect the mapping and reconcile the held record; do not create another order reference to bypass it
REVIEW after a timeout or 500Creation may already have committedInspect customer/license rows and saved request_id before any retry
OUTBOX_DECRYPTION_FAILEDWrong or lost private encryption keyRestore the correct secure backup; do not create a second license merely to obtain a key

Next steps

Review idempotency and uncertain outcomes, then test the recipient's activation flow using the delivered key.

Was this page helpful?

Choose an answer

Do not include personal data, license keys or credentials. Retained for up to 30 days. This is not a support inbox.

Search documentation

Arrow keys to move, Enter to open, Escape to close. Search stays in your browser.

Cookie settings