Search by

Laravel client for ValorPay: hosted payment pages, transaction lookups, card tokens, subscriptions and webhook hints.

Package info

github.com/CaneBayComputers/valorpay

pkg:composer/canebaycomputers/valorpay

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-10-10 03:27 UTC

This package is auto-updated.

Last update: 2026-10-10 05:06:31 UTC


README

canebaycomputers/valorpay is a small Laravel client for ValorPay. It covers the jobs most apps have:

  • Take a payment on a ValorPay-hosted page.
  • Confirm the payment from ValorPay's own transaction records.
  • Bill on a schedule with subscriptions charged to a stored card token.
  • Handle webhooks safely, treating them only as hints about what to re-check.

Three rules hold everywhere in this package:

  1. Amounts are in cents. 2500 means $25.00. This matches what ValorPay's transaction list reports.
  2. Card numbers never touch your app. Customers type their card on ValorPay's page. Subscriptions charge the token ValorPay saved from that sale. The package has no way to send a card number.
  3. Webhooks are hints, not proof. In practice ValorPay doesn't sign its webhooks, so anyone can post one to your URL. Check the transaction list before you act on one.

Install

Requires PHP 8.3+ and Laravel 12 or 13 (Guzzle 7 or 8).

composer require canebaycomputers/valorpay

Laravel discovers the service provider and the ValorPay facade on its own. Add your credentials to .env:

VALORPAY_APP_ID='your-valor-connect-app-id'
VALORPAY_APP_KEY='your-virtual-terminal-app-key'
VALORPAY_EPI=2xxxxxxxxx
# Optional:
# VALORPAY_URL=https://securelink-prod.valorpaytech.com:4430
# VALORPAY_LINK_VALID_DAYS=1   # how long a hosted page can be paid
# VALORPAY_TIMEOUT=20          # seconds per call

Put the app ID and key in single quotes, because they can contain $, # and %. To publish the config file (optional):

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

The credentials are checked when the first call is made, not when the app boots. An app without them still runs; a ValorPay call throws ValorPayException instead.

1. Take a payment

use CaneBayComputers\ValorPay\Facades\ValorPay;

$page = ValorPay::createHostedPage(
    cents: 2500,
    invoiceNumber: 'INV1001',            // your reference: it's how you find the payment again
    description: 'Web hosting, November',
    successUrl: route('checkout.done', $invoice),
    failureUrl: route('checkout.failed', $invoice),
    customerName: $invoice->business_name, // cleaned to what ValorPay accepts
);

$invoice->update(['valorpay_page_uid' => $page->uid]);

return redirect($page->url);

To cancel a page so it can no longer be paid, call ValorPay::cancelHostedPage($uid). It returns true when the page is cancelled, already cancelled or expired. It never throws.

Things to know:

  • The success URL proves nothing. Anyone can open it. Confirm the payment with step 2.
  • Don't pass email or phone. Either one makes ValorPay send its own "E-INVOICE, please pay" email or SMS, which confuses someone who is paying in the browser right now. No setting stops it: notification_status: 0, invoice_mode, the ?pagesale variant with customer_*: 0, and updating the page afterwards all still send it. is_customer_info_avail only hides an email you supplied; the page still shows its Email and Cell Phone boxes. This package sends neither, so send your own receipt.
  • Don't pass zip. The hosted page has no ZIP field, and a supplied zip makes the page hang on a JavaScript error.
  • Private-IP URLs get a 403. ValorPay's firewall refuses success and failure URLs that point at a private IP (http://10.x.x.x, http://192.168.x.x). Local development needs a public URL.
  • Other fields: validDays: overrides how long the link lasts. extra: [...] passes any other documented hosted-page field.

2. Confirm the payment

$payments = ValorPay::transactionsForInvoice('INV1001', since: $invoice->created_at);

if ($sale = $payments->paidSale(2500)) {
    $invoice->markPaid(txnId: $sale->id(), approval: $sale->approvalCode());
}

transactionsForInvoice() returns every record ValorPay has under that invoice number on your EPI: sales, declines, refunds and voids. Transactions gives you the checks:

Method Returns
paidSale($cents) / isPaid($cents) The approved sale for exactly that amount that still stands (not voided, reversed or refunded).
standingSales() Every approved sale that still stands. More than one means a double charge.
reversals() Refunds, voids and reversals, plus sales that were later undone.
all(), first(), isEmpty(), count(), filter(), onEpi() Ordinary list access. Transactions is also iterable.

Each ValorPayRecord provides id(), type(), amountInCents(), approvalCode(), isApproved(), isStandingSale(), isReversal(), invoiceNumber(), payerEmail() (the email typed on ValorPay's page), token(), subscriptionId(), epi(), describe() (for example "SALE 123 ($25.00)"), and get('ANY_FIELD') for any other field.

How the lookup works, so you don't have to:

  • Exact matches only. ValorPay matches invoice numbers as substrings: a search for INV1 also returns INV10. Only exact matches are kept.
  • Your EPI only. ValorPay lists every device on the merchant account. Only your EPI's records are kept.
  • US Eastern dates. ValorPay's dates are US Eastern. The search runs from a day before since to a day after today.
  • Long ranges and many results. Ranges longer than ValorPay's 90-day limit are split into several queries, and every page of results is fetched.

For other searches there is ValorPay::transactions($filter, $text, $since, $until = null). $filter is one of VT_INVOICE, SUBSCRIPTION_ID, TXNID, RRN, APPROVAL_CODE, AMOUNT, EPI, MID, ADDNL_VAL1, ADDNL_VAL2, or '' for everything. It returns raw substring matches from every device.

3. Bill on a schedule (subscriptions)

ValorPay subscriptions charge a stored card token on a schedule. You get the token from a hosted-page sale, so the customer types their card exactly once, on ValorPay's own page:

use CaneBayComputers\ValorPay\Subscriptions\Frequency;
use CaneBayComputers\ValorPay\Subscriptions\SubscriptionRequest;

// 1. The first month is an ordinary hosted page (step 1), confirmed with the transaction list (step 2).
$sale = ValorPay::transactionsForInvoice('INV1001', $invoice->created_at)->paidSale(2500);

// 2. The sale carries the card token. Subscribe from next month on.
$subscriptionId = ValorPay::createSubscription(new SubscriptionRequest(
    cents: 2500,
    cardToken: $sale->token(),
    customerName: 'Acme Co',
    zip: '29486',
    startsOn: now()->addMonth(),         // date of the first scheduled charge
    frequency: Frequency::Monthly,       // Weekly, BiWeekly, Monthly, Quarterly, Annually, Daily
    description: 'Web hosting',
    email: 'billing@acme.example',
));

Collect the ZIP code yourself. Add Subscription requires a billing ZIP, but hosted-page sales never record one (BILLING_ZIPCODE is always blank), so ask for it on your own form.

SubscriptionRequest checks every field against ValorPay's rules before anything is sent. A bad ZIP, a 13-character invoice number or a 31st-day charge throws InvalidArgumentException before any call goes out. Its arguments:

Argument Default Notes
cents, cardToken, customerName, zip, startsOn required The name is cut to 25 letters. ZIP+4 is accepted.
frequency Monthly
chargeOn day of startsOn Day of the month, 1 to 30 (ValorPay has no 31). Weekly and bi-weekly schedules need a weekday, 0 to 6.
payments null (until cancelled) Total number of charges.
invoiceNumber '' Up to 12 letters and digits. Don't reuse the first sale's invoice number. Recurring charges are matched by subscription ID anyway.
description, email, phone, descriptor descriptor is the text on the card statement, up to 30 characters.
retries ValorPay's default 0 to 10 retries after a charge fails for insufficient funds.
validateCard true Only checks the card when the subscription is created. The first charge happens on startsOn.
notifyOnFailure false Whether ValorPay emails about failed charges.
taxCents 0
extra [] Any other documented field (for example billing_street_name). Card fields are refused.

Managing subscriptions:

ValorPay::deactivateSubscription($id);   // pause; already paused or finished is fine
ValorPay::activateSubscription($id);     // resume; already active is fine
ValorPay::deleteSubscription($id);       // for good; already deleted is fine
ValorPay::updateSubscription($id, new SubscriptionRequest(...)); // send the whole subscription

ValorPay::subscription($id);             // ?Subscription, active or not
ValorPay::subscriptions(active: true);   // list<Subscription>; an empty list when there are none

To change the card, take a new hosted-page sale and call updateSubscription() with that sale's token.

A Subscription provides id(), isActive(), amountInCents(), chargeDay(), startsFrom(), paymentsMade(), failedAttempts(), maskedCard(), invoiceNumber(), description(), email() and get('field'). ValorPay's list doesn't include the frequency or the next charge date, so store those in your own database.

What a subscription actually charged comes from the transaction list, the same as one-off payments:

$charges = ValorPay::transactionsForSubscription($subscriptionId, since: $subscription->started_at);
$lastGood = $charges->standingSales()->first(); // newest first

ValorPay::cardToken($token) shows what a token points to: masked number, brand and card type.

4. Webhooks: a hint to re-check, never proof

ValorPay posts {event, data} for sales, refunds and recurring billing. In practice ValorPay doesn't sign them (no Valor-Signature header, checked 2026-09-29), and the event names aren't even consistent ("Subcription Deleted" is spelled that way). Use the payload only to decide what to look up:

use CaneBayComputers\ValorPay\Facades\ValorPay;

class ValorPayWebhookController
{
    public function __invoke(Request $request)
    {
        // Your own prefix ("INV") skips payments other apps make on the same merchant.
        $hint = ValorPay::webhookHint($request->getContent(), invoicePrefix: 'INV');

        foreach ($hint->invoiceNumbers as $number) {
            $invoice = Invoice::firstWhere('number', $number);
            $sale = $invoice ? ValorPay::transactionsForInvoice($number, $invoice->created_at)->paidSale($invoice->cents) : null;
            // Act only on $sale: that's ValorPay's own record.
        }

        foreach ($hint->subscriptionIds as $id) {
            // Re-check with ValorPay::transactionsForSubscription($id, ...) and ValorPay::subscription($id).
        }

        return response()->noContent();
    }
}

WebhookHint reads invoice_no, invoicenumber and reference_descriptive_data.invoicenumber for invoices, and both subscription_id and subscriptionid for subscriptions. It accepts data as a single object or as a list, and ignores anything that isn't JSON.

If ValorPay ever does sign its webhooks, Webhooks\WebhookSignature checks the Valor-Signature and Valor-Timestamp headers against VALORPAY_WEBHOOK_SECRET: WebhookSignature::fromConfig()->problem($body, $signature, $timestamp) returns null when the signature is valid, or the reason it isn't. Even with a valid signature, re-check before you act.

Errors

Every call throws CaneBayComputers\ValorPay\ValorPayException when ValorPay can't be reached, refuses a call, or isn't configured. The one exception is cancelHostedPage(), which never throws. $exception->errorNo holds ValorPay's code when it sent one:

Code Meaning
S00 Success. Never thrown.
D07 Bad app key.
D27 Invalid EPI.
E31 No subscription. subscriptions() returns [] and subscription() returns null instead of throwing.
E59 / E60 / E62 Already deactivated / already active / already deleted. Treated as success.
E65 The subscription is already completed, so it can't be activated.
E77 Invalid card token.
A38 / A39 Hosted page already cancelled / already expired. cancelHostedPage() returns true.

Testing your app

The client uses Laravel's HTTP client, so Http::fake() covers it:

Http::fake(['*valorpaytech.com*' => Http::response(['status' => 'SUCCESS', 'data' => [[
    'REF_TXN_ID' => '1', 'EPI' => config('valorpay.epi'), 'INVOICE_NO' => 'INV1001', 'TXN_TYPE' => 'SALE',
    'RESPONSE_CODE' => '00', 'APPROVAL_CODE' => 'TEST01', 'BASE_AMOUNT' => '2500',
]]])]);

What has and hasn't been tried against production

Checked against production with read-only calls (2026-10-09):

  • The transaction list, including date-range splitting and pagination.
  • Exact invoice matching and card-token lookup.
  • Subscription listing. An empty list comes back as E31.
  • Hosted pages and cancelling them, proven by zeltro-license, where this code comes from.

Run live by cbc-admin (2026-10-10):

  • createSubscription() with the TOKEN from a $25.00 hosted-page sale. It returned a subscription ID, and the subscription list shows it active, with the right amount, invoice number, charge day and a YYYY-MM-DD start date. So ValorPay accepts a hosted-sale token, and it accepts the YYYY-MM-DD format this package sends (its docs say YYYYMMDD).
  • validateCard: true did not add a charge. The hosted-page sale was the only charge.
  • Twenty test hosted pages, which found the email, phone and zip problems listed under step 1.

Not yet seen live:

  • The first scheduled charge, and with it:
    • Which EPI recurring charges land on. Because of this, transactionsForSubscription() doesn't filter by EPI.
    • Whether a recurring charge carries the subscription's invoice number.
    • Exactly where the recurring-billing webhook puts the subscription ID. WebhookHint reads both documented spellings.
  • Updating, pausing, resuming and deleting a subscription.

Development

composer install
vendor/bin/phpunit

The tests fake every call. Never put real credentials, EPIs or customer data in this repository: it is public.

License

MIT