Complete, security-first Tap Payments integration for Laravel: charges, authorize/capture, refunds, tokens, saved cards, invoices, payouts, marketplace and verified webhooks.

Maintainers

Package info

github.com/ahmed-laggoun/tap

pkg:composer/ahmedlaggoun/tap

Transparency log

Statistics

Installs: 23

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0 2026-08-28 18:08 UTC

This package is auto-updated.

Last update: 2026-08-29 08:56:22 UTC


README

Complete, security-first Tap Payments integration for Laravel.

Latest Version Tests PHP Version Downloads License

$charge = $tap->checkout(
    amount: $tap->money(10000, 'SAR'),          // 100.00 SAR
    customer: Customer::existing($user->tap_customer_id),
    idempotencyKey: IdempotencyKey::forOrder((string) $order->id, $order->total()),
);

return redirect()->away($charge->paymentUrl);   // they have NOT paid yet.

Why this package

Payment integrations fail in quiet, expensive ways. This one is built around the failures that actually happen with Tap:

  • INITIATED is not paid. Creating a charge succeeds and returns INITIATED. verify() asserts status, amount, currency and environment together, and throws — a missed return value cannot become a free order.
  • KWD, BHD, OMR and JOD use three decimal places. Amounts are integers in minor units, currency-aware, and never round-trip through a float.
  • Webhook amounts must be hashed as "1.00", not "1". Getting this wrong makes every genuine webhook look forged. It is the most common Tap integration bug, and it is handled for you.
  • Idempotency keys must be stable. A key generated per attempt disables the protection while appearing to work. Keys are required, not optional, and IdempotencyKey derives them from the order.
  • Card data must not reach your server. No API here accepts a PAN without an explicit PCI declaration in two places, and free-text fields are scanned so a card number cannot slip through in description or metadata.
  • Tap has two different hashstrings. One you generate for the SDKs, one you verify from webhooks — different field lists, opposite directions, same secret key. Both are implemented, and neither is confused for the other.

Full API coverage, one interface per resource, framework-free domain layer, PHPStan level 6, and a test suite that targets tamper cases specifically.

Contents

Requirements

PHP 8.2, 8.3, 8.4
Laravel 10, 11, 12
Extensions json, mbstring

Installation

composer require ahmedlaggoun/tap
php artisan vendor:publish --tag=tap-config

The service provider is auto-discovered. Add your credentials from the Tap dashboard (goSell → API Credentials):

TAP_ENV=test
TAP_SECRET_KEY=sk_test_xxxxxxxxxxxx
TAP_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxx
TAP_CURRENCY=SAR
TAP_REDIRECT_URL=https://yourapp.com/checkout/return

# Webhooks — the route registers only when a path token is set.
TAP_WEBHOOK_PATH_TOKEN=          # php artisan tinker → Str::random(40)
TAP_WEBHOOK_URL=https://yourapp.com/webhooks/tap/<that token>

# Recommended in production.
TAP_ALLOWED_HOSTS=yourapp.com    # allowlist for redirect + webhook URLs
TAP_WEBHOOK_CACHE_STORE=redis    # replay protection needs a shared store

The secret key belongs in the backend only. It authenticates your API calls and keys webhook verification — see Security.

The package refuses to boot on a misconfiguration rather than failing at the first live charge: a sk_live_* key with TAP_ENV=test (or the reverse), a publishable key in the secret slot, a non-HTTPS base URL, or a webhook path token that would corrupt the route.

Quick start

Type-hint Tap anywhere Laravel resolves dependencies — a controller method, a job, a constructor — or reach for app(Tap::class). There is no facade; the container binding is the whole API.

1. Start the charge. The customer has not paid yet — redirect them.

use AhmedLaggoun\Tap\Application\Tap;
use AhmedLaggoun\Tap\Domain\Data\Customer;
use AhmedLaggoun\Tap\Support\IdempotencyKey;

public function pay(Order $order, Tap $tap)
{
    $charge = $tap->checkout(
        amount: $order->total(),                        // a Money instance
        customer: Customer::make($user->name, $user->email),
        idempotencyKey: IdempotencyKey::forOrder((string) $order->id, $order->total()),
        orderReference: (string) $order->id,
    );

    $order->update(['charge_id' => $charge->id]);
    session(['order_id' => $order->id]);

    return redirect()->away($charge->paymentUrl);
}

2. Verify when they return. Tap appends ?tap_id=chg_xxx.

use AhmedLaggoun\Tap\Domain\Exceptions\PaymentVerificationException;

public function return(Request $request, Tap $tap)
{
    $order = Order::findOrFail(session('order_id'));

    try {
        $charge = $tap->verify($request->query('tap_id'), $order->total());
    } catch (PaymentVerificationException $e) {
        report($e);

        return redirect()->route('checkout.failed')
            ->with('message', $tap->retrieve($request->query('tap_id'))->customerMessage());
    }

    $order->markPaid($charge->id);

    return redirect()->route('orders.show', $order);
}

Take only tap_id from the redirect. Query parameters are in the address bar and can be edited before your route sees them. The order id comes from the session — otherwise one valid payment can be replayed against a different order. verify() re-fetches the charge with your secret key and asserts everything server-side.

3. Confirm by webhook. The redirect can be abandoned; the webhook is the reliable signal. See Webhooks.

API coverage

Every resource is a separate contract. $tap->refunds() hands back the ability to refund and nothing else — a class that issues refunds should not also be able to create charges.

Resource Accessor Operations
Charges $tap->charges() create, retrieve, verify, update, list, download
Authorize $tap->authorizes() authorize, retrieve, verify, capture, void, update, list, download
Refunds $tap->refunds() refund, retrieve, list, download
Tokens $tap->tokens() saved card, Apple Pay, Google Pay, Samsung Pay, network token, encrypted card, raw card (PCI-gated)
Cards $tap->cards() retrieve, list, delete, verify
Customers $tap->customers() create, retrieve, update, list
Invoices $tap->invoices() create, retrieve, update, cancel, remind, list
Intents (SmartPOS) $tap->intents() create, retrieve, cancel, list
Payouts $tap->payouts() retrieve, list, download
Marketplace $tap->marketplace() businesses, merchants, destinations, leads, Connect URLs
Files $tap->files() upload
Reports $tap->reports() dispute downloads, paged streaming exports
SDK signing $tap->sdk() hashString for the Flutter / iOS / Android / Web SDKs

Plus signed webhook verification, replay protection and queued processing.

Charges

For anything beyond checkout(), build a ChargeRequest:

use AhmedLaggoun\Tap\Domain\Data\{ChargeRequest, PaymentSource, Receipt};

$charge = $tap->charge(ChargeRequest::make(
    amount: $tap->money(10000, 'SAR'),
    customer: Customer::existing($user->tap_customer_id),
    source: PaymentSource::named(PaymentSource::ALL),   // hosted page, all methods
    redirectUrl: route('checkout.return'),
    idempotencyKey: IdempotencyKey::forOrder((string) $order->id, $order->total()),
    description: "Order #{$order->id}",
    orderReference: (string) $order->id,
    metadata: ['order_id' => (string) $order->id],
    threeDSecure: true,
    saveCard: false,
    statementDescriptor: 'YOURSHOP',
    receipt: Receipt::byEmail(),
));

Payment sources. PaymentSource::ALL (every enabled method), CARD, KNET, BENEFIT, MADA, or PaymentSource::token($id) for a token from the Card SDK.

Charge status. CAPTURED is the only status where money has moved. $charge->status->isPending(), isFailure() and isFinal() cover the rest; $charge->customerMessage() gives text that is safe to show a customer.

Authorize, capture and void

Hold funds now, decide later — pre-orders, rentals, deposits, or stock you have to confirm.

use AhmedLaggoun\Tap\Domain\Data\AutoAction;

$hold = $tap->hold(
    amount: $order->total(),
    customer: Customer::existing($user->tap_customer_id),
    idempotencyKey: IdempotencyKey::forOrder((string) $order->id, $order->total()),
    auto: AutoAction::voidAfterHours(72),
);

// Once you can fulfil — returns a Charge, with its own id:
$charge = $tap->authorizes()->capture($hold->id);
$order->markPaid($charge->id);

// Or release the hold:
$tap->authorizes()->void($hold->id);

AUTHORIZED is not paid. The issuer has reserved the funds; no money has moved and the hold expires. Shipping on AUTHORIZED without capturing sends goods against a reservation the bank can still withdraw.

Capture returns a Charge, not an Authorize. Tap has no capture endpoint — capturing posts to /charges with the authorize as its source, producing a real charge. Store that id; it is what a later refund needs.

The auto policy is required, and defaults to VOID. There is no "leave it alone" option, because an indefinite hold on someone's money is not a neutral state. One of the two outcomes will happen while nobody is watching, and auto-capture bills a customer because a background job failed. Use AutoAction::captureAfterHours() only where an uncaptured hold genuinely means the sale completed — a finished hotel stay, a returned rental.

Partial captures are supported, but capture the final amount in one call: most acquirers release the remainder and will not let you take it afterwards.

Refunds

use AhmedLaggoun\Tap\Domain\Enums\RefundReason;

$amount = $tap->money(3000, 'SAR');

$refund = $tap->refund(
    chargeId: $order->charge_id,
    amount: $amount,
    reason: RefundReason::RequestedByCustomer,
    idempotencyKey: IdempotencyKey::forRefund($order->charge_id, $amount),
);

// Or refund everything, reading the amount from the charge itself:
$refund = $tap->refundFully($order->charge_id, RefundReason::Duplicate);

A refund is not finished when the call returns. Tap commonly answers PENDING or IN_PROGRESS and settles later by webhook. Code that checks "did the call succeed" rather than $refund->isSettled() will happily refund the same customer twice.

Customers

Creating a customer once and passing its id keeps personal data out of every later request — the charge carries {"id": "cus_..."} instead of a name, email and phone number. Fewer copies of PII in flight, and one place to honour a deletion request.

$customer = $tap->customers()->createCustomer(
    Customer::make('Layla', 'layla@example.com', phoneCountryCode: '966', phoneNumber: '512345678')
);

$user->update(['tap_customer_id' => $customer->id]);

// Markets where a mobile number is the primary identifier:
Customer::withPhone('Layla', '965', '51234567');

Saved cards

use AhmedLaggoun\Tap\Domain\Data\TokenRequest;

// 1. Verify the card with a zero-value check, and save it.
$verification = $tap->cards()->verifyCard(
    source: PaymentSource::token($request->input('tap_token')),
    customer: Customer::existing($user->tap_customer_id),
    currency: 'SAR',
);

if ($verification->requiresRedirect()) {
    return redirect()->away($verification->paymentUrl);   // 3D Secure
}

$user->update(['tap_card_id' => $verification->assertValid()->savedCardId()]);

// 2. Charge it later — mint a fresh single-use token from the saved card.
$token = $tap->tokens()->createToken(
    TokenRequest::fromSavedCard($user->tap_customer_id, $user->tap_card_id)
);

$charge = $tap->charge(ChargeRequest::make(
    source: $token->toSource(),
    /* ... */
));

A token is not a saved payment method. Every token is single-use and expires within minutes. Store the card id and mint a fresh token per charge. Storing a token and replaying it fails — sometimes immediately, sometimes after the expiry window, which makes it look intermittent.

INITIATED on a verification is not a pass either: it means 3D Secure is required and the payer must be redirected before there is any result.

Wallets. TokenRequest::fromApplePay(), fromGooglePay(), fromSamsungPay(), fromNetworkToken() and fromEncryptedCard() cover the other flows. Managing saved cards:

$tap->cards()->listCards($user->tap_customer_id);
$tap->cards()->retrieveCard($user->tap_customer_id, $cardId);
$tap->cards()->deleteCard($user->tap_customer_id, $cardId);

Every card operation is scoped to a customer id, so a leaked card id is not actionable on its own.

Invoices

use AhmedLaggoun\Tap\Domain\Data\{InvoiceRequest, Order, OrderItem};
use AhmedLaggoun\Tap\Domain\Enums\InvoiceMode;

$invoice = $tap->invoices()->createInvoice(InvoiceRequest::make(
    order: Order::make([
        OrderItem::make('Annual plan', $tap->money(120000, 'SAR')),
        OrderItem::make('Setup', $tap->money(25000, 'SAR')),
    ]),
    customer: Customer::existing($user->tap_customer_id),
    dueAt: now()->addDays(7),
    expiresAt: now()->addDays(30),
    mode: InvoiceMode::Invoice,
    notifyByEmail: true,
));

return $invoice->url;   // the hosted payment page

The order total is computed from the line items rather than supplied — an invoice whose stated total disagrees with its lines is a dispute waiting to happen, and the customer will be right.

$invoice->isPaid(), isOpen(), isDraft(), hasExpired(), and assertPaid($expected) for the throwing check. Also updateInvoice(), cancelInvoice(), remindInvoice() and listInvoices().

SENT and VIEWED both have a working payment page. Neither means anyone paid.

SmartPOS (intents)

Push a payment to a physical terminal.

$intent = $tap->intents()->createIntent(
    order: Order::forAmount($tap->money(4500, 'SAR')),
    customer: Customer::existing($user->tap_customer_id),
    merchantId: config('tap.merchant_id'),
    terminalId: $terminal->id,
    webhookUrl: config('tap.webhook_url'),
    idempotencyKey: IdempotencyKey::forOperation('pos', $sale->id),
);

The webhook URL is required, not optional: a POS intent has no redirect and no payer-facing page, so the webhook is the only channel that will ever report what the terminal did.

Cancellation is narrower than it looks — Tap accepts it only while the intent is INITIATED and a terminal has picked it up. $intent->isCancellable() checks both.

Payouts and reports

use AhmedLaggoun\Tap\Domain\Data\{DateRange, ListQuery};

$payouts = $tap->payouts()->listPayouts(
    ListQuery::forPeriod(DateRange::lastDays(30))
);

A payout aggregates many charges net of fees and refunds, so its amount will not match the sum of the day's captures — and should not be expected to. $payout->bankReference is what appears on the bank statement.

Listing is cursor-paginated:

$query = ListQuery::forPeriod(DateRange::lastDays(7))->status('CAPTURED')->limit(50);
$page  = $tap->charges()->list($query);

while ($page->hasMore) {
    $page = $tap->charges()->list($query->startingAfter($page->nextCursor));
}

For bulk exports, use the streaming variants:

foreach ($tap->reports()->streamCharges(ListQuery::forPeriod($period)) as $csv) {
    foreach ($csv->records() as $row) {
        // $row is keyed by the CSV header row
    }
}

A single download() call is a trap. Tap caps an export at 100,000 rows and reports has_more in a response header, not the body — so a busy month returns a perfectly well-formed CSV missing most of the month, with nothing in the payload to say so. Reconciliation then shows a shortfall that looks like missing money rather than a missing page. streamCharges() follows the cursor to the end.

Also streamRefunds(), streamAuthorizes() and downloadDisputes().

Marketplace

Split a charge across destinations:

use AhmedLaggoun\Tap\Domain\Data\Split;

ChargeRequest::make(
    amount: $tap->money(10000, 'SAR'),
    destinations: [Split::amount('dest_seller', $tap->money(8500, 'SAR'))],
    /* ... */
);

Splits are checked against the charge total before sending, because Tap accepts an over-allocated split at request time and fails it at settlement, days later, after the goods have shipped.

Onboarding — businesses, merchants, destinations, leads and Connect URLs:

$lead    = $tap->marketplace()->createLead([...]);
$connect = $tap->marketplace()->createConnectUrl($lead->id, route('onboarding.done'));

return redirect()->away($connect->string('connect.url'));

These return TapObject, a typed reader over the raw payload (->string('entity.license.number'), ->int(), ->bool(), ->array(), ->money()) rather than a fixed DTO. Those payloads are regulatory documents whose shape varies by country, entity type and licence, and Tap extends them as it opens markets; a rigid model would break on expansion and silently drop the field you needed. The money-moving resources are modelled properly, where the field list is stable and getting one wrong costs money.

Files

use AhmedLaggoun\Tap\Domain\Enums\FilePurpose;

$file = $tap->files()->uploadFile(
    path: storage_path('app/evidence.pdf'),
    purpose: FilePurpose::DisputeEvidence,
    title: 'Delivery confirmation',
);

Public links are refused on purposes carrying identity or regulated data — identity documents, PCI evidence, dispute evidence and signatures cannot be given a no-auth URL by accident.

SDK checkout (hashString)

If you use Tap's Flutter, iOS, Android or Web SDK, the SDK configuration needs a hashString. It must be computed on your server, because it is an HMAC keyed with your secret key — which is exactly why the SDK cannot compute it itself.

public function checkoutConfig(Order $order, Tap $tap)
{
    return response()->json(
        $tap->sdkCheckout(
            amount: $order->total(),
            customer: Customer::existing($user->tap_customer_id),
            transactionReference: "order-{$order->id}",
            idempotencyKey: IdempotencyKey::forOrder((string) $order->id, $order->total()),
        )->toArray()
    );
}

For anything beyond the defaults, build a SdkCheckoutRequest. Every documented setting is typed:

use AhmedLaggoun\Tap\Domain\Data\{SdkCardOptions, SdkCheckoutRequest};
use AhmedLaggoun\Tap\Domain\Enums\{SdkLanguage, SdkPaymentType, SdkThemeMode};

$config = $tap->sdk()->sign(
    SdkCheckoutRequest::make($order->total(), $customer, "order-{$order->id}")
        ->order($basket)                                  // itemised, total must match
        ->idempotencyKey($key)
        ->saveCard()
        ->language(SdkLanguage::fromLocale(app()->getLocale()))
        ->themeMode(SdkThemeMode::Dynamic)                // follows the device
        ->paymentType(SdkPaymentType::All)
        ->supportedCurrencies(['SAR', 'AED'])
        ->supportedSchemes(['VISA', 'MASTERCARD'])
        ->cardOptions(SdkCardOptions::make(
            collectHolderName: true,
            cardScanner: true,
            saveCardOption: 'all',
        ))
        ->applePay(availableOnClient: $request->boolean('apple_pay_available'))
        ->metadata(['user_id' => (string) $user->id])
);

Also available: authorize() with autoVoid() / autoCapture() for hold-first sessions, subscription() and airline() for those payload blocks, redirectUrl(), postUrl(), threeDSecure(), supportedPaymentMethods(), supportedRegions(), supportedCountries(), supportedPaymentTypes(), and extra() for anything a newer SDK adds before this package models it.

toArray() returns the complete configuration in the nested shape the SDKs consume — every key from Tap's published Flutter example, which the test suite pins against:

{
  "hashString": "8f2c…",
  "amount": "100.00",
  "selectedCurrency": "SAR",
  "gateway":  { "publicKey": "pk_test_…", "merchantId": "mer_…" },
  "customer": { "id": "cus_…", "firstName": "Layla", "lastName": "Hassan", "email": "",
                "phone": { "countryCode": "974", "number": "33445566" } },
  "order":    { "id": "", "currency": "SAR", "amount": "100.00", "items": [ ] },
  "transaction": {
    "mode": "charge",
    "charge": {
      "reference":    { "transaction": "order-42", "order": "order-42", "idempotent": "order_…" },
      "saveCard": false,
      "threeDSecure": true,
      "post": "https://yourapp.com/webhooks/tap/…",
      "redirect": { "url": "https://yourapp.com/checkout/return" }
    }
  },
  "language": "en", "themeMode": "dynamic", "paymentType": "ALL",
  "supportedCurrencies": "ALL", "supportedPaymentMethods": "ALL",
  "supportedPaymentTypes": [], "supportedRegions": [],
  "supportedSchemes": [], "supportedCountries": [],
  "cardOptions": { "showBrands": true, "collectHolderName": true, },
  "isApplePayAvailableOnClient": true
}

Tap recomputes the signature on its side, so a device that alters the amount, currency, reference or webhook URL after signing gets its session refused rather than charged. That is the entire purpose — the SDK runs somewhere you do not control. Only the digest leaves your server; an HMAC does not reveal its key.

Four shape differences from the REST API that fail quietly, and that this handles for you:

  • publicKey is nested under gateway, and the currency is selectedCurrency, not currency.
  • The SDK config is camelCasefirstName, countryCode, cardNFC — where the REST API is snake_case. Sending the REST shape loses those fields silently.
  • redirect is an object with a url, but post is a bare string. They sit side by side in transaction.charge looking symmetrical, and are not.
  • The transaction reference is signed and written into transaction.charge.reference. They must match, so one value writes both.

Amounts are strings everywhere, including inside order.items.

Send the amount exactly as toArray() renders it. It is the string that was hashed. Re-deriving it on the client, or letting a JSON encoder turn "100.00" into 100, is the usual cause of a hash mismatch.

phone is always present, with empty strings when unknown. The SDKs type countryCode and number as non-nullable strings, so a null fails while the config is parsed on the device — which surfaces as a generic client-side error, typically indistinguishable from a dead network, for exactly the customers who have no number on file.

Note the two different hashstrings. This one is outgoing — you generate it, Tap verifies it. The one under Webhooks is incoming — Tap generates it, you verify it. Different field lists, opposite directions, same secret key. The package handles both and never confuses them.

Two ambiguities in Tap's own documentation

Decimal places. Tap's Flutter sample formats the amount with toStringAsFixed(2) — always two places — while the webhook hashstring and the API generally use the currency's own precision, which is three for KWD, BHD, OMR and JOD. Their published example muddies it further by sending "amount": "5" for a KWD session, with no decimals at all.

This package follows the currency, matching everything else Tap does. For SAR, AED, USD and every other two-place currency the rules agree and the question never arises. If you charge in a three-place currency, run one live test — a mismatch fails loudly at session start, the SDK is refused and nothing is charged — then pin the other behaviour if needed:

$tap->sdk()->sign($request, decimals: 2);

auto.time units. The REST API documents this in hours; the SDK configuration reference labels the same field minutes; the published example ships time: 100 without saying which. autoVoid() and autoCapture() therefore take the number you verified against your own account rather than converting — holding a customer's funds for sixty times longer than intended is not a default worth guessing at.

Webhooks

Tap signs webhooks with HMAC-SHA256 keyed with your secret key, in the hashstring header. The signature covers id, amount, currency, status and created, so an attacker cannot inflate an amount or flip a status to CAPTURED without invalidating it.

Set TAP_WEBHOOK_PATH_TOKEN and the route registers itself. Then listen:

use AhmedLaggoun\Tap\Application\Events\TapPaymentUpdated;

Event::listen(function (TapPaymentUpdated $event) {
    if (! $event->isCaptured()) {
        return;
    }

    $order = Order::where('charge_id', $event->charge->id)->firstOrFail();

    // Still check the amount. Knowing a charge is captured is not knowing it
    // paid for *this* order.
    $order->markPaid($event->charge->assertMatches($order->total())->id);
});

$event->charge, ->refund, ->authorize and ->invoice are each populated for the matching object type, alongside isCaptured(), isAuthorized(), isRefunded(), isInvoicePaid() and isUnconfirmed().

Three details decide whether verification works, and all three are easy to get wrong. The package handles them:

  1. The amount must be formatted to the currency's decimal places. Tap sends "amount": 1.0; PHP stringifies that as "1", but the hash was computed over "1.00" — or "1.000" for KWD. Get this wrong and every genuine webhook looks forged.
  2. Invoices use a different field list (x_updated instead of the gateway and payment references). One layout for both silently fails half your webhooks.
  3. Comparison must be constant-time. Tap's own documentation example uses ==, which is timing-variable and — because both operands are hex strings — vulnerable to PHP's numeric juggling when a digest matches 0e\d+. This package uses hash_equals.

On top of verification, the endpoint rejects events whose live_mode contradicts your environment, de-duplicates on id and status (one charge legitimately emits several events as it moves INITIATED → CAPTURED, so de-duplicating on id alone would swallow the one that matters), and re-fetches the object from the API before dispatching.

That re-fetch is not about trust — the signature already proves authenticity. It is about freshness: a captured body stays cryptographically valid forever, so a replayed webhook could arrive after the charge was refunded.

Tap retries a failed POST twice more and then marks it ERROR permanently, so the controller verifies, de-duplicates and queues — nothing slow runs inline.

Replay protection needs a cache store shared across every web process. The default array driver is per-process and forgets on each request, which silently disables it. Set TAP_WEBHOOK_CACHE_STORE=redis in production.

Amounts

Tap is a decimal API, and the number of decimal places is load-bearing.

use AhmedLaggoun\Tap\Domain\Data\Money;

Money::of(10000, 'SAR')->toHashString();       // "100.00"
Money::of(1000, 'KWD')->toHashString();        // "1.000"  — three places
Money::fromDecimalString('19.99', 'SAR');      // from your own order total
Money::fromApi(0.318, 'KWD')->minor;           // 318

$total = $price->plus($shipping)->minus($discount);

KWD, BHD, OMR, JOD and TND use three decimals; most others use two. Amounts are held as integers so no float survives a round trip, decimals are scaled with string arithmetic (exact at any magnitude), and currency is part of equality — so a KWD amount can never satisfy a SAR order.

Idempotency

Every charge and refund requires an idempotency key. Tap honours it for 24 hours: repeat a request with the same key and you get the original response instead of a second transaction.

Derive it from the order, not the attempt. Str::uuid() per attempt disables the protection entirely — which is exactly the failure it exists to prevent.

use AhmedLaggoun\Tap\Support\IdempotencyKey;

IdempotencyKey::forOrder((string) $order->id, $order->total());
IdempotencyKey::forRefund($charge->id, $amount);

// A customer legitimately retrying after a decline opts in deliberately:
IdempotencyKey::forOrder((string) $order->id, $order->total(), attempt: 2);

The amount is part of the hash: an order whose total changed is a different charge, and reusing the key would return the old response for the old amount.

Error handling

Every failure implements TapException, so one catch block covers the package.

Exception Meaning
PaymentVerificationException Verification failed. Do not fulfil.
InvalidChargeException Rejected locally — nothing reached Tap, nothing was charged.
InvalidAmountException Bad amount or currency mismatch.
AuthenticationException 401/403 or a credential error code.
RateLimitException 429. Back off exponentially.
ApiException Tap answered and refused. ->errors, ->primaryCode, ->isTransient().
TransportException No usable response — DNS, TLS, timeout, malformed body.
WebhookException Webhook rejected before processing.
ConfigurationException Unsafe configuration. Thrown at boot.
try {
    $charge = $tap->verify($request->query('tap_id'), $order->total());
} catch (PaymentVerificationException $e) {
    // Amount mismatch deserves a human, not a retry.
    report($e);
} catch (TapException $e) {
    // Everything else.
}

A TransportException on a charge is the ambiguous case — it may or may not have been applied. That is what the idempotency key exists for: repeat the request with the same key within 24 hours and Tap returns the original response.

Security

This package is built for a threat model where the attacker controls the browser and can edit anything in the address bar.

Verification and money

  • verify() asserts status, amount, currency and live_mode against your configured environment — a test charge can never settle a live order.
  • Marketplace splits are checked against the charge total before sending.
  • Amounts are integers in minor units; currency is part of equality.

Card data (PCI DSS)

  • No API accepts a PAN without pciCertified: true at the call site and TAP_PCI_CERTIFIED=true in config. Two gates, because the cost of getting this wrong is an audit finding, not a bug report.
  • PaymentSource::token() rejects a bare 12–19 digit string.
  • description, metadata and order line items are scanned for card numbers. Detection is Luhn-checked, so a long order reference is not a false positive. A PAN in a free-text field is stored by Tap, written to your logs, and shipped to whatever aggregator those logs feed — the quiet way an integration that never touches card data lands in PCI scope.

Network and URLs

  • Redirect and webhook URLs must be absolute HTTPS. Localhost, private ranges and cloud metadata addresses are refused. Set TAP_ALLOWED_HOSTS and nothing outside it is accepted; matching is on a dot boundary, so example.com admits pay.example.com but not example.com.evil.test. Tap sends the payer to the redirect URL, so an unvalidated one is an open redirect originating from a payment domain — about the most credible phishing hop available.
  • TLS verification is pinned per request, so a global Http::globalOptions(['verify' => false]) elsewhere in the app cannot silently disable it here.
  • Charges and refunds are never retried automatically. get() retries; mutating verbs do not.
  • Resource ids are validated before entering a URL path.

Webhooks

  • Constant-time HMAC comparison, live-mode enforcement, replay protection keyed on id and status.
  • Rejects on body size and content type before any cryptography — an unauthenticated public endpoint is a resource-exhaustion target as much as a forgery one.
  • Fails closed: if the replay store is unreachable the endpoint returns 503 rather than processing unprotected. Tap retries a 5xx.
  • The route is stateless — no session, no CSRF, no cookies.

Secrets and logging

  • The secret key does double duty: it authenticates API calls and keys webhook verification. A leak lets an attacker charge cards and forge webhooks your own system will accept as genuine. Regenerate in the dashboard at the first suspicion, before investigating.
  • $tap->publishableKey() is the only key intended for a view.
  • Log context is scrubbed — keys, hashstrings, tokens, card fields, names, emails, phones and IPs — matched by substring, so customer_email is caught as well as email. Surviving strings pass through the PAN detector, because the field carrying a card number is usually one nobody thought to name.
  • Decline reasons are not shown verbatim. $charge->customerMessage() maps response codes to safe text: telling a cardholder their bank ran a risk check helps a card tester tune their next attempt and helps nobody else.

Found a vulnerability? Please do not open a public issue — see SECURITY.md.

Configuration

config/tap.php after publishing. Every value has an env equivalent.

Key Env Default Notes
environment TAP_ENV test Cross-checked against the key prefix at boot.
base_url TAP_BASE_URL https://api.tap.company/v2 v3 endpoints are derived from this.
secret_key TAP_SECRET_KEY Backend only. Also keys webhook verification.
publishable_key TAP_PUBLISHABLE_KEY The only key safe in a view.
merchant_id TAP_MERCHANT_ID
currency TAP_CURRENCY SAR
redirect_url TAP_REDIRECT_URL Where the payer returns.
webhook_url TAP_WEBHOOK_URL Where Tap POSTs results.
allowed_redirect_hosts TAP_ALLOWED_HOSTS [] Comma-separated. Empty = baseline rules only.
pci_certified TAP_PCI_CERTIFIED false Leave false unless you hold an AoC.
http.timeout TAP_TIMEOUT 20
http.connect_timeout TAP_CONNECT_TIMEOUT 5
http.retry_times TAP_RETRY_TIMES 3 Reads only.
http.download_timeout TAP_DOWNLOAD_TIMEOUT 120 CSV exports are slow.
webhooks.path TAP_WEBHOOK_PATH webhooks/tap
webhooks.path_token TAP_WEBHOOK_PATH_TOKEN 16–128 chars. No token, no route.
webhooks.enforce_live_mode TAP_WEBHOOK_ENFORCE_LIVE true
webhooks.replay_ttl TAP_WEBHOOK_REPLAY_TTL 86400
webhooks.cache_store TAP_WEBHOOK_CACHE_STORE default Use a shared store in production.
webhooks.queue TAP_WEBHOOK_QUEUE default
logging.channel TAP_LOG_CHANNEL default

Swapping an implementation

Every resource is bound to its own interface, so you can decorate one without touching the rest:

$this->app->extend(ChargeGateway::class, fn ($gateway) => new AuditedCharges($gateway));

Architecture

src/Domain/            framework-free core — contracts, value objects, enums
src/Application/       use cases: Tap, TapPaymentUpdated, ProcessTapWebhook
src/Infrastructure/    HTTP transport, one adapter per resource, webhooks, wiring
src/Support/           Sensitive (PAN detection), UrlGuard, IdempotencyKey,
                       Redactor, Payload

Dependencies point inward: Domain imports nothing from Application or Infrastructure, Application imports nothing from Infrastructure, and only Infrastructure knows Tap exists.

Two honest caveats. Domain is free of the Laravel framework but uses Illuminate\Support\Collection as a return type, which ships in the standalone illuminate/collections — it runs outside Laravel, but it is not zero-dependency. Application uses the queue and event traits, so it is Laravel-bound by design. And WebhookVerifier sits in Infrastructure/Contracts rather than Domain/Contracts because its signature names an Illuminate\Http\Request; an interface that mentions an HTTP request is not a domain concept.

Notes on Tap's API

Behaviour worth knowing whether or not you use this package:

  • amount is declared as an integer in Tap's OpenAPI schema while every example shows a decimal (1.000, 0.318).
  • response.code is a zero-padded string ("000") that arrives as an integer in some payloads, where 000 becomes 0.
  • Timestamps are Unix milliseconds, sometimes as strings. The webhook hash is computed over the raw string, so it must never be reformatted before hashing.
  • A 200 can carry an errors array. HTTP status alone is not the outcome.
  • Cancellation is spelled both CANCELLED and CANCELED across the docs.
  • Leads and Connect are on v3, everything else on v2. A v2 call to a v3 path returns a 404 that reads as "no such lead" rather than "wrong version".
  • Several paths are singular/card, /intent, /merchant, /destination — while the documentation index lists them in the plural.
  • Capture has no endpoint of its own. It is POST /charges with the authorize id as the source.
  • Download endpoints return CSV with pagination state in response headers.
  • An empty string unsets a metadata key on an update.

Testing

composer test      # Pest
composer analyse   # PHPStan level 6
composer format    # Pint
composer check     # all three

The suite targets tamper and misuse cases specifically: inflated amount with a valid signature, status flipped to CAPTURED, a hash signed with a different key, missing header, wrong environment, INITIATED treated as paid, AUTHORIZED treated as paid, wrong currency, path traversal in a resource id, the three-decimal KWD hash, a live charge settling a test order, an allowlist bypass via example.com.evil.test, a PAN hidden in a free-text field, a raw card token without PCI declaration, an over-capture, an over-allocated split, and a public link on an identity document.

Testing your own integration

The package ships a fake transport, so your tests exercise the real payload building without touching the network:

use AhmedLaggoun\Tap\Infrastructure\Contracts\TapTransport;
use AhmedLaggoun\Tap\Testing\FakeTapTransport;

it('charges the order total', function () {
    $this->app->instance(TapTransport::class, $tap = new FakeTapTransport([
        'POST /charges' => ['id' => 'chg_1', 'status' => 'INITIATED',
                            'amount' => 100.0, 'currency' => 'SAR'],
    ]));

    $this->post(route('checkout', $order))->assertRedirect();

    expect($tap->payloadFor('POST', '/charges')['amount'])->toBe(100.0);
});

Responses are keyed "VERB /uri", so you can assert a capture went to POST /charges and not PUT /authorize/{id} — the kind of mistake that stays invisible until money moves. payloadFor() throws when the call was never made, called() and trace() cover the rest.

Tap's test card numbers work against TAP_ENV=test.

Contributing

Pull requests are welcome. Please run composer check before opening one, and add a test for any behaviour change — especially anything touching amounts, verification or webhooks.

For security issues, see SECURITY.md rather than the issue tracker.

Changelog

See CHANGELOG.md. Semantic versioning: a breaking change to any interface in src/Domain/Contracts/ is a major bump.

Credits

Built by Ahmed Laggoun. Not affiliated with or endorsed by Tap Payments.

License

MIT. See LICENSE.