laravel-inpost-pay maintained by beziworld
Laravel InPost Pay
InPost Pay merchant integration for Laravel (the one-click checkout in the InPost Mobile app):
- client for the InPost Pay (Basket App) API: OAuth2 client credentials with token caching, basket binding for Widget 2.0, basket updates, binding checks and desynchronisation, order updates (status, description, shipment numbers), basket and order listings, payment methods, transactions and signed refunds
- Merchant Backend API routes called by InPost Pay (basket details, binding confirmation, basket events, basket
removal, order creation, order details, order events) with RSA signature verification of every request
(
x-signature, public keys fetched by version and cached, 240 s timestamp window) - contracts the application implements:
BasketProviderandOrderHandler; the package does the HTTP, JSON, signature and error format work - typed basket builder (
Basket,Product,DeliveryOption,Consent,Price) producing the documented structure, and typed DTOs for everything InPost Pay sends - transaction webhooks v2 (payments, refunds, settlements) with SHA-512 signature verification
- Laravel events for every callback, sandbox and production, runtime credentials with
using()
Why this package
Existing InPost Pay integrations are platform plugins: the official Magento 2 modules (inpost/magento2-module-inpost-pay
and its Hyvä/GraphQL add-ons) and a Shopware 6 plugin (crehler/inpost-pay). There is no Laravel package and no
framework-agnostic PHP client on Packagist. This package brings the complete merchant side of InPost Pay to any
Laravel shop: both directions of the API, the RSA and SHA-512 signature schemes (verified against the official
examples), and a basket model that matches the documentation field by field.
Installation
composer require beziworld/laravel-inpost-pay
php artisan vendor:publish --tag=inpost-pay-config # optional
INPOST_PAY_CLIENT_ID=... # merchant panel (production) or issued by InPost (sandbox)
INPOST_PAY_CLIENT_SECRET=...
INPOST_PAY_SANDBOX=true # sandbox-api.inpost.pl / sandbox-login.inpost.pl
INPOST_PAY_MERCHANT_SECRET=... # merchant panel, API Keys tab: signs refunds and webhooks
INPOST_PAY_ROUTE_PREFIX=inpost-pay # Merchant Backend API base: https://shop.test/inpost-pay
INPOST_PAY_WEBHOOK_PATH=inpost-pay/events # transaction webhooks URL
Register https://your-shop/inpost-pay as the merchant backend URL and https://your-shop/inpost-pay/events as the
transaction webhook URL in the InPost Pay merchant panel. Sandbox credentials are requested through
inpostpay.pl/kontakt.
Implement the merchant side
InPost Pay calls your shop to read baskets and create orders. Implement two contracts and bind them:
use Beziworld\InpostPay\Contracts\BasketProvider;
use Beziworld\InpostPay\Contracts\OrderHandler;
$this->app->bind(BasketProvider::class, App\InpostPay\ShopBasketProvider::class);
$this->app->bind(OrderHandler::class, App\InpostPay\ShopOrderHandler::class);
use Beziworld\InpostPay\Contracts\BasketProvider;
use Beziworld\InpostPay\Data\{Basket, BasketEvent, BindingConfirmation, Consent, DeliveryOption, Price, Product};
use Beziworld\InpostPay\Enums\{ConsentRequirement, DeliveryType};
use Beziworld\InpostPay\Exceptions\MerchantApiError;
final class ShopBasketProvider implements BasketProvider
{
public function basket(string $basketId): Basket
{
$cart = Cart::find($basketId) ?? throw MerchantApiError::basketNotFound($basketId);
return new Basket(
basePrice: Price::fromGross($cart->total, 23),
products: $cart->items->map(fn ($item) => new Product(
productId: (string) $item->product_id,
name: $item->name,
basePrice: Price::fromGross($item->price, 23),
quantity: (string) $item->quantity,
availableQuantity: (string) $item->stock,
image: $item->image_url,
ean: $item->ean,
))->all(),
delivery: [
(new DeliveryOption(DeliveryType::Apm, Price::fromGross('12.99', 23), now()->addDays(2)))
->withOption('Paczka w Weekend', 'PWW', Price::fromGross('5.00', 23)),
new DeliveryOption(DeliveryType::Courier, Price::fromGross('16.99', 23), now()->addDays(1)),
],
consents: [new Consent('terms', 'Regulamin sklepu', route('terms'), '2024-01', ConsentRequirement::RequiredAlways)],
expiresAt: now()->addDays(7),
);
}
public function confirmBinding(BindingConfirmation $confirmation): Basket
{
// $confirmation->phoneNumber, ->browserTrusted, ->browserId, ->isSuccessful()
return $this->basket($confirmation->basketId);
}
public function handleEvent(BasketEvent $event): Basket
{
// $event->type (PRODUCTS_QUANTITY, PROMO_CODES, RELATED_PRODUCTS), ->quantities, ->promoCodes, ->relatedProducts
Cart::find($event->basketId)->applyInpostChanges($event->quantities, $event->promoCodes);
return $this->basket($event->basketId);
}
public function unbind(string $basketId): void
{
Cart::find($basketId)?->forgetInpostBinding();
}
}
OrderHandler::create(OrderRequest $order) receives the customer, delivery (deliveryType, deliveryPoint,
deliveryAddress, deliveryCodes), invoice data, accepted consents and InPost discounts, and returns the created
order in the documented order_details structure. handleEvent(OrderEvent $event) receives payment updates
($event->isPaid() for AUTHORIZED).
Errors thrown as MerchantApiError (basketNotFound(), orderNotFound(), badRequest() or a custom code) are
returned as {"error_code": ..., "error_message": ...} with their HTTP status; other exceptions are reported and
answered with HTTP 500 INTERNAL_SERVER_ERROR.
Routes
| Method | Path (under INPOST_PAY_ROUTE_PREFIX) |
Contract method | Event |
|---|---|---|---|
| GET | /v1/izi/basket/{basketId} |
BasketProvider::basket |
|
| POST | /v1/izi/basket/{basketId}/confirmation |
BasketProvider::confirmBinding |
BasketBindingConfirmed / BasketBindingRejected |
| POST | /v1/izi/basket/{basketId}/event |
BasketProvider::handleEvent |
BasketChanged |
| DELETE | /v1/izi/basket/{basketId}/binding |
BasketProvider::unbind |
BasketUnbound |
| POST | /v1/izi/order |
OrderHandler::create |
OrderCreated |
| GET | /v1/izi/order/{orderId} |
OrderHandler::find |
|
| POST | /v1/izi/order/{orderId}/event |
OrderHandler::handleEvent |
OrderEventReceived, OrderPaid |
| POST | INPOST_PAY_WEBHOOK_PATH |
TransactionWebhookReceived, PaymentAuthorized, PaymentDeclined, RefundCompleted, RefundDeclined, SettlementReceived |
Every Merchant Backend API request is verified: the public key of the x-public-key-ver version is fetched from
/v1/izi/signing-keys/public/{version} (cached for a day), its SHA-256 must match x-public-key-hash, the
x-signature is checked with SHA256withRSA over base64(base64(sha256(body)),merchant_external_id,version,timestamp)
and the timestamp must be within 240 seconds. Failures return HTTP 401 INVALID_SIGNATURE.
INPOST_PAY_VERIFY_SIGNATURE=false disables the check for local debugging only.
Call InPost Pay
use Beziworld\InpostPay\Data\OrderUpdate;
use Beziworld\InpostPay\Facades\InpostPay;
// Widget 2.0: key for the basket binding widget
$bindingKey = InpostPay::bindBasket($cart->id);
// The basket changed in the shop: send the full current basket
InpostPay::updateBasket($cart->id, $basket);
$binding = InpostPay::basketBinding($cart->id, $browserId); // ->linked, ->browserTrusted, ->maskedPhoneNumber
InpostPay::unbindBasket($cart->id, realized: true); // basket turned into an order in the shop
// Orders: shipment numbers are mandatory, statuses and descriptions shown in the app
InpostPay::addShipments($orderId, ['520000011395200025754311'], 'Wysłane');
InpostPay::completeOrder($orderId, 'Zrealizowane'); // ORDER_COMPLETED
InpostPay::rejectOrder($orderId, 'Anulowane'); // ORDER_REJECTED
InpostPay::updateOrder($orderId, OrderUpdate::description('W trakcie realizacji'));
// Back office
InpostPay::baskets(pageIndex: 0, pageSize: 50);
InpostPay::orders(orderId: $orderId);
InpostPay::paymentMethods(); // ['CARD', 'BLIK_CODE', ...]
// Transactions and refunds (signed with INPOST_PAY_MERCHANT_SECRET)
$page = InpostPay::transactions(['order_id' => $orderId, 'status' => ['CAPTURED']]);
$refund = InpostPay::refund($transactionId, amount: '49.99', externalRefundId: 'RMA-17');
$refund->status; // RefundStatus::Pending | Success | Failed
$refund->commandId; // X-Command-ID: pass it again to repeat a refund with an unknown outcome
Other stores or credentials at runtime:
InpostPay::using(['client_id' => $store->inpost_client_id, 'client_secret' => $store->inpost_secret, 'sandbox' => false])
->addShipments($orderId, [$trackingNumber]);
Errors
| Exception | When |
|---|---|
ApiException |
InPost Pay rejected the request: httpStatus(), errorCode(), body() |
AuthenticationException |
the token endpoint rejected the client credentials |
RateLimitExceeded |
HTTP 429, retryAfter() in seconds |
InvalidConfiguration |
missing client credentials or merchant secret, or contracts not bound |
InvalidSignature |
a request or webhook failed signature verification (handled by the routes) |
MerchantApiError |
thrown by your contracts to answer InPost Pay with an error code |
Reads are retried twice on connection errors and HTTP 5xx; writes (basket updates, order updates, refunds) are never retried automatically. A request answered with HTTP 401 is repeated once with a fresh token.
Development
composer install
composer ci # pint --test, phpstan (level 8), pest
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an integration? Get in touch through beziworld.eu.
Other packages by the author
laravel-inpost · laravel-paynow · laravel-przelewy24 · laravel-payu · laravel-tpay
License
MIT. See LICENSE.md. This package is not affiliated with InPost S.A.