Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

@getpayin/paylink

CIPackagistPHPLicense

Official server-side PHP SDK for the PayLink payment integration API — hosted checkouts, payment operations, card tokens, recurring mandates, and webhook verification.

The SDK signs every request exactly the way the PayLink servers expect, so you never reproduce the HMAC signing rules yourself. It is framework-agnostic (plain PHP, Symfony, Laravel, a queue worker) with no required Composer dependencies beyond ext-curl, ext-json, and PSR-3.

Requirements

  • PHP 8.2+
  • ext-curl, ext-json

Installation

composer require getpayin/paylink

Quick start

useGetPayin\Paylink\Core\PaylinkClient;
$paylink = newPaylinkClient(
publicToken: getenv('PAYLINK_PUBLIC_TOKEN'),
hashToken: getenv('PAYLINK_HASH_TOKEN'), // secret — server-side only
);
$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00', // pass money as strings for an exact wire form'currency' => 'USD',
]);
// ['checkoutUrl' => ..., 'invoiceId' => ..., 'expiresAt' => ...]// Redirect the payer to the returned checkout URL.header('Location: '.$checkout['checkoutUrl']);

Pass 'iframe' => true to enable embedded (iframe) checkout instead of a full-page redirect:

$checkout = $paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
'iframe' => true, // embed the returned checkout URL in an <iframe>
]);

Then embed the returned checkout URL on your page. The <iframe> needs allow="payment *" so Apple Pay and Google Pay work inside the frame, and your page must listen for the completion message — in iframe mode the checkout signals the parent via postMessage instead of redirecting:

<iframesrc="CHECKOUT_URL" allow="payment *"
style="width:100%;min-height:640px;border:0" title="Secure checkout"></iframe><script>addEventListener('message',function(e){if(e.origin!=='https://pay.getpayin.com')return;// your API base originif(!e.data||e.data.type!=='paylink_payment')return;window.location.href=e.data.success ? '/thank-you' : '/checkout?failed=1';});</script>

The embedding page's origin must exactly match your integration's registered Origin, or the browser blocks framing and the message never arrives.

Request parameters are passed as camelCase arrays; results come back as camelCase arrays.

Using Laravel? Don't construct the client by hand — inject the configured PaylinkClient from the container instead. See Using it inside Laravel.

Payment operations

$paylink->payments->void(['invoiceId' => 12345]);
$paylink->payments->settle(['invoiceId' => 12345, 'amount' => '50.00']);
$paylink->payments->reverseAuthorization(['invoiceId' => 12345]);
$status = $paylink->payments->checkStatus(['invoiceId' => 12345]);
// ['invoiceId' => 12345, 'paidStatus' => '...', 'authCode' => '...']// Refunds are idempotent when you pass an idempotency key — safe to retry:$refund = $paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234',
);
// ['invoiceId' => ..., 'paidStatus' => ..., 'authCode' => ..., 'refundAmount' => ...]

Card tokenization

$result = $paylink->cards->tokenize([
'firstName' => 'Jane',
'lastName' => 'Doe',
'cardNumber' => '4111111111111111',
'cardExpiryMonth' => '12',
'cardExpiryYear' => '2030',
'cardCvv' => '123',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$token = $result['token'];
$paylink->cards->charge([
'cardToken' => $token,
'initiator' => 'merchant',
'firstName' => 'Jane',
'lastName' => 'Doe',
'currency' => 'USD',
'price' => '100.00',
'product' => 'Monthly rebill',
'country' => 'EG',
'address' => '1 Main St',
'city' => 'Cairo',
]);
$paylink->cards->revoke(['cardToken' => $token]);

For US and CA billing addresses, also pass the state fields the API requires: usState + postalCode (US) or canadaState + postalCode (CA).

Recurring mandates

$mandate = $paylink->recurring->create(
[
'firstName' => 'Sam',
'lastName' => 'Doe',
'email' => 'sam@example.com',
'orderTitle' => 'Gold subscription',
'orderAmount' => '250.00',
'currency' => 'USD',
'cadenceInterval' => 'month',
'cadenceCount' => 1,
'totalCycles' => 12,
'consentText' => 'I authorise recurring monthly charges.',
],
idempotencyKey: 'sub-signup-42',
);
$paylink->recurring->status($mandate['mandateId']);
$paylink->recurring->pause($mandate['mandateId']);
$paylink->recurring->resume($mandate['mandateId']);
$paylink->recurring->cancel($mandate['mandateId']);

Idempotency

Retrying a write after a network error or timeout risks performing it twice. To make that safe, pass an idempotencyKey — the SDK sends it as the Idempotency-Key header and the server returns the original result instead of charging, refunding, or creating a second time. Keys are scoped per integration and capped at 64 characters.

EndpointA replay with the same key returns
invoices->createthe original invoice and checkoutUrl
vcc->chargethe original charge
cards->chargethe original charge
payments->refundthe original refund
recurring->createthe original mandate
$paylink->vcc->charge([/* card + order fields */], idempotencyKey: 'vcc-order-1234');
$paylink->cards->charge([/* token + order fields */], idempotencyKey: 'tok-order-1234');
$paylink->invoices->create([/* customer + order fields */], idempotencyKey: 'order-1234');

Reusing a key with a different request — for example recurring->create with changed terms, or payments->refund for a different amount — is rejected as a conflict: a PaylinkApiException whose isIdempotencyConflict() is true (HTTP 409). Only the endpoints above honor the header.

Verifying webhooks

Pass the raw request body (or a decoded array) to verify(). It recomputes the signature with your hashToken and compares in constant time, throwing PaylinkSignatureException on a mismatch.

useGetPayin\Paylink\Core\Exceptions\PaylinkSignatureException;
useGetPayin\Paylink\Core\Webhook\WebhookEventType;
try {
$event = $paylink->webhooks->verify(file_get_contents('php://input'));
// $event->event, $event->invoiceId, $event->success, $event->raw, ...
} catch (PaylinkSignatureException) {
http_response_code(400);
exit;
}
if ($event->type() === WebhookEventType::InvoicePaid) {
// Fulfil the order.
}

PayLink webhook signatures carry no timestamp, so verification does not protect against replay. Pair it with your own idempotency keyed on invoice_id.

Error handling

Every failure extends GetPayin\Paylink\Core\Exceptions\PaylinkException:

ErrorWhen
PaylinkConfigExceptionInvalid client configuration (missing tokens, non-positive timeout).
PaylinkApiExceptionThe API returned an error. Carries status, errors, raw, retryAfterMs, and isIdempotencyConflict() (409), isRateLimited() (429), isForbidden() (403 — e.g. card tokenization or recurring payments not enabled).
PaylinkSignatureExceptionA webhook signature did not verify.
PaylinkConnectionExceptionNetwork failure or timeout (no HTTP response).
useGetPayin\Paylink\Core\Exceptions\PaylinkApiException;
try {
$paylink->payments->refund(['invoiceId' => 12345, 'amount' => '10.00']);
} catch (PaylinkApiException$error) {
if ($error->isIdempotencyConflict()) {
// a refund with this idempotency key already exists
}
}

Retries and rate limiting

Every integration endpoint is rate limited server-side, so 429s are an expected condition under burst traffic rather than an edge case. The SDK retries transient failures — 429, 5xx, connection errors, and timeouts — with exponential backoff and full jitter, honoring the server's Retry-After header when present.

A request is only ever replayed when replaying it cannot double-charge:

ReplayedNot replayed
All GETs (recurring->status)vcc->charge, cards->charge, cards->tokenize
Any call you pass an idempotencyKey toinvoices->create, recurring->create without a key
payments->checkStatus (a pure read)recurring->cancel / pause / resume

So to make a refund safely retryable, pass an idempotency key — otherwise a failed refund surfaces immediately and is yours to handle:

$paylink->payments->refund(
['invoiceId' => 12345, 'amount' => '10.50'],
idempotencyKey: 'refund-order-1234', // now retried on 429/5xx
);

Tune or disable retries per client:

newPaylinkClient(publicToken: $pub, hashToken: $secret, maxRetries: 0); // off

timeoutMs applies to each attempt, so worst-case wall time is roughly (maxRetries + 1) × timeoutMs plus backoff. For requests the SDK will not replay, PaylinkApiException::$retryAfterMs exposes the server's backoff hint (milliseconds) so you can schedule your own retry:

} catch (PaylinkApiException $error) {
if ($error->isRateLimited()) {
// reschedule using $error->retryAfterMs
}
}

Amounts and precision

Signatures are computed over the exact bytes sent on the wire. To avoid any floating-point ambiguity, pass monetary amounts as strings (e.g. '10.50'). Integers are accepted and stringified, but strings give you full control.

Logging

The SDK logs to any PSR-3 logger you pass — retries at warning, exhausted retries at error, rejected webhooks at warning, and a timed info line per request. Every context array is masked, so a token, secret, signature, or card field never reaches the log. With no logger, output is silently discarded.

In Laravel, point it at a dedicated channel:

// config/logging.php'channels' => [
'paylink' => [
'driver' => 'daily',
'path' => storage_path('logs/paylink.log'),
'level' => 'debug',
'days' => 14,
],
],
useIlluminate\Support\Facades\Log;
$paylink = newPaylinkClient(
publicToken: config('services.paylink.public_token'),
hashToken: config('services.paylink.hash_token'),
logger: Log::channel('paylink'),
);

Outside Laravel, pass a Monolog logger (or any PSR-3 implementation) the same way.

Using it inside Laravel

The package ships an auto-discovered service provider and a Paylink facade — no manual wiring. Set the credentials in your environment:

PAYLINK_PUBLIC_TOKEN=pub_...PAYLINK_HASH_TOKEN=secret_...PAYLINK_LOG_CHANNEL=paylink

Then inject the configured client — constructor injection is the idiomatic way, and the container resolves the singleton from your config:

useGetPayin\Paylink\Core\PaylinkClient;
useIlluminate\Http\RedirectResponse;
finalclass CheckoutController
{
publicfunction__construct(
privatereadonlyPaylinkClient$paylink,
) {}
publicfunctionstore(): RedirectResponse
{
$checkout = $this->paylink->invoices->create([
'firstName' => 'John',
'lastName' => 'Doe',
'email' => 'john@example.com',
'orderTitle' => 'Gold Plan',
'orderAmount' => '250.00',
'currency' => 'USD',
]);
returnredirect()->away($checkout['checkoutUrl']);
}
}

Method injection (route actions, jobs, listeners) and the facade resolve the same singleton:

useGetPayin\Paylink\Core\PaylinkClient;
useGetPayin\Paylink\Laravel\Facades\Paylink;
publicfunctioncheckout(PaylinkClient$paylink)
{
return$paylink->invoices->create([/* ... */]);
}
Paylink::invoices()->create([/* ... */]);
$event = Paylink::webhooks()->verify(request()->getContent());

The provider binds the client through LaravelHttpTransport (Laravel's HTTP client, so Http::fake() works in tests) and logs to your configured channel. Publish the config to tune it:

php artisan vendor:publish --tag=paylink-config

Outside auto-discovery (or to run more than one integration), construct the client directly and pass any transport — implement GetPayin\Paylink\Core\Http\Transport to plug in Guzzle, a PSR-18 client, or a fake:

useGetPayin\Paylink\Laravel\LaravelHttpTransport;
$paylink = newPaylinkClient(
publicToken: config('paylink.public_token'),
hashToken: config('paylink.hash_token'),
transport: newLaravelHttpTransport(),
logger: Log::channel('paylink'),
);

Security

  • hashToken is a signing secret. Never ship it to a browser, a mobile app, or a client bundle. It is redacted from var_dump() and json_encode() on the client, but load it from an environment variable or secret manager and never log it.
  • vcc->charge and cards->tokenize accept raw PAN/CVV, which puts your server in PCI scope. Prefer the hosted checkout (invoices->create) or card tokens where possible.

See SECURITY.md for the full security policy and how to report a vulnerability privately.

API reference

The full HTTP API — endpoints, fields, error codes, and test cards — is documented in the PayLink API reference: https://pay.getpayin.com/docs/payment_integration/index.html

Development

composer install
composer check # pint (lint) + phpstan (level 7) + pest

See CONTRIBUTING.md — the signing contract and how to keep it in sync with the server.

License

MIT — see LICENSE.

About

Official server-side PHP SDK for the PayLink payment integration API — checkouts, payments, card tokens, recurring mandates, and webhook verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages