Skip to content
ECOMLARAVEL12 min read

Nova Poshta and Payment Integration: Where It Breaks Most Often.

ЄвгенійTech lead, 11 years in PHP / Laravel
Published
Updated

Nova Poshta and online payment integrations break not where you expect. The API call itself usually works the first time. Problems start at the boundaries: when a branch disappears from the directory, a payment callback arrives twice, and a waybill is created for the wrong weight. This article is for store owners and Laravel developers: the 10 most common places where things break, and how we check them.

Everything below is based on the practice of Laravel projects with Nova Poshta delivery and payment through LiqPay or similar services. Check the exact field names against the provider's current documentation: APIs change, principles do not.

Why these integrations cause the most trouble

Delivery and payment live outside your code. You do not control their availability, response format or reaction time. On top of that, both integrations concern money: an error in how a banner is displayed costs nothing, while an error in payment costs an order and the customer's trust.

There is another reason. These integrations are rarely tested along the full path. A developer checks the "happy path" in the sandbox, the client clicks once in test mode, and everything looks fine. Real customers do something else: they close the tab during 3-D Secure, choose a parcel locker for a 30 kg parcel, type a phone number with spaces.

If you are ordering ecommerce development, ask the contractor exactly how they check these scenarios. The answer "we tested in the sandbox" is not enough.

Nova Poshta: directories and branch selection

1. Outdated city and branch directories

A typical mistake: a developer loads the list of branches into the database once and forgets about it. Branches open, close, move and temporarily stop working. A customer chooses a branch that no longer exists, and the waybill is not created.

What to do:

  • Refresh the directory on a schedule (php artisan schedule), not by hand. For a small store once a day at night is enough.
  • Store in the order not only warehouse_ref but also a text snapshot: city, branch number, address. A month later the branch may change, and the manager needs to see what the customer chose.
  • Mark branches that disappeared from the directory instead of deleting them. Otherwise old orders lose their link.

2. Weight and dimension limits

Not every branch accepts cargo of any weight. Parcel lockers have strict limits on parcel size and weight, and some branches do not accept oversized cargo. The branch directory has fields with maximum weight and dimensions, and they must be used.

A classic bug: there is a heavy product in the cart, checkout offers all branches, the customer picks a parcel locker, and the manager then calls and asks them to change the branch. That is lost time and some orders the customer will simply cancel.

Correct behavior: calculate the total cart weight, filter branches by the limits, and if none fits, show a clear message. For this, products must have weight and dimensions filled in. That is a requirement for content, not only for code.

3. City search and settlements with the same name

Ukraine has many settlements with identical names in different regions. A text search without specifying the region returns the wrong village. Use the settlement search method (Address/searchSettlements) and store the Ref, not the name. In the dropdown show the region and district so the customer can see which "Lisove" they are choosing.

Separately check input with mistakes: "Dnepr" instead of "Dnipro", extra spaces, the apostrophe in "Kam'ianske" (there are three different apostrophe characters). Autocomplete has to survive this.

Creating a waybill (TTN)

4. Validation errors when creating a waybill

A waybill is created with the InternetDocument/save method, and this is where the most "small" failures happen: the recipient's phone in the wrong format, a missing surname, a declared value of zero, a weight below the allowed minimum. If your code creates the waybill right at the moment of checkout, the customer sees an error they do not understand.

Our practice is this:

  1. Normalize the phone on input: remove spaces, parentheses and hyphens, convert it to the 380XXXXXXXXX format.
  2. Create the waybill not in the customer's HTTP request but in a background job (queue job).
  3. If the API returned an error, store the error text in the order and show it to the manager in the admin panel. Do not swallow the exception.
  4. Add a "Create waybill again" button for the manager.

An example of a job skeleton:

class CreateShipmentJob implements ShouldQueue
{
    public int $tries = 5;
    public array $backoff = [30, 120, 600, 1800];

    public function __construct(public int $orderId) {}

    public function handle(NovaPoshtaClient $np): void
    {
        $order = Order::findOrFail($this->orderId);

        // Idempotency: do not create a second waybill for the same order
        if ($order->ttn_number) {
            return;
        }

        $response = $np->createWaybill($order);

        if (! $response['success']) {
            $order->update(['shipment_error' => json_encode($response['errors'], JSON_UNESCAPED_UNICODE)]);
            throw new ShipmentFailed($response['errors']);
        }

        $order->update([
            'ttn_number' => $response['data'][0]['IntDocNumber'],
            'shipment_error' => null,
        ]);
    }
}

The key here is not the syntax but the ttn_number check at the start. Without it, a retry of the job will create a second waybill and you will pay for two.

5. Cash on delivery and money transfer back

Cash on delivery adds complexity: the amount of the return money transfer must match the order amount minus the part already paid. Typical bugs:

  • the customer paid partially (discount, promo code, bonuses), but the waybill was created with the full cash-on-delivery amount;
  • the amount is rounded differently in the store and in the waybill, and a discrepancy of cents appears that accounting then hunts for weeks;
  • for orders paid by card, cash on delivery should not be created at all, but because of a condition in the code it is created anyway.

Test combinations: full prepayment, partial prepayment, cash on delivery, promo code plus cash on delivery. This is ten minutes of manual checking that saves you from complaints.

6. API unavailability and timeouts

The Nova Poshta API can be slow, sometimes unavailable, and sometimes returns an error only for certain requests. If branch search at checkout goes straight to the API without a cache and without a wait-time limit, a slow response freezes the whole form.

The minimum worth having:

  • an explicit HTTP client timeout (in Laravel Http::timeout(8)->retry(2, 200));
  • a directory cache, so checkout does not depend on the API during search;
  • a fallback: if the directory is unavailable, allow entering the address manually and pass the order to a manager for clarification.

This is a business decision, not only a code one: what matters more, not losing the order or getting perfect data. For a store the order almost always comes first.

Online payment: callbacks, statuses, duplicates

7. Trusting the return page instead of the server callback

The most dangerous mistake in payment. After payment the customer returns to the "Thank you for your order" page (result_url). If you mark the order as paid right there, you take a double risk: the customer can close the tab before returning and the payment will not be recorded, while an attacker can open this address manually and pretend to have paid.

The correct scheme: the order status is changed only by the payment system's server callback (server_url in LiqPay), and the return page only shows the current status and polls it if needed.

8. Unverified signature and repeated callbacks

The payment system sends a callback with a signature. If you do not verify it, anyone can send a POST request and mark an order as paid. In LiqPay the signature is calculated from the private key and the request data; the formula is in the documentation, and it must be implemented and covered by a test.

Payment systems also repeat callbacks if your server responded with anything other than 200 or did not respond in time. So the handler must be idempotent: a repeated callback with the same status must neither create a second order, nor deduct stock twice, nor send a second email.

public function handle(Request $request): Response
{
    $data = $request->input('data');
    $signature = $request->input('signature');

    if (! hash_equals($this->sign($data), $signature)) {
        abort(403);
    }

    $payload = json_decode(base64_decode($data), true);

    DB::transaction(function () use ($payload) {
        $order = Order::where('public_id', $payload['order_id'])
            ->lockForUpdate()
            ->firstOrFail();

        // Terminal state reached already: ignore repeated callbacks
        if ($order->payment_status === 'paid') {
            return;
        }

        if ($payload['status'] === 'success' && (float) $payload['amount'] === (float) $order->total) {
            $order->markPaid($payload['payment_id']);
        }
    });

    return response('OK');
}

Pay attention to two things: lockForUpdate() stops two simultaneous callbacks from processing the order in parallel, and the amount comparison protects against tampering. The amount is rarely mentioned, and that is a mistake: a customer who changed parameters on the browser side should be refused.

9. Unhandled intermediate and failed statuses

A payment has more states than "success" and "error". There are awaiting confirmation, 3-D Secure, processing, refund and bank decline. If the code only knows success, everything else hangs in limbo: the customer paid but the order is "awaiting payment", or the reverse.

For every status, make a decision and record it in a test:

Payment statusWhat we do with the order
successmark as paid, reserve the product, send a confirmation
awaiting / processingkeep "in processing", do not cancel on a timer too early
declined / errorlet the customer retry payment on the same order
refundreturn stock, update the status, notify the manager

A special case: the customer paid but the callback never arrived. Let a job run every few minutes that checks the payment system for the status of "hanging" orders. It is cheaper than sorting things out by hand from bank statements.

10. Mismatch between the order and the payment

When the customer changes the cart after opening the payment page, or a manager edits the order after the payment was created, the amount in the payment system and in the store diverges. The result: 1,200 paid, an order for 1,450, and accounting in a panic.

The solution: after the payment is created, the order is locked against amount changes. If a change is needed, create a new payment and void the old one. Also add reconciliation: every day compare paid orders with the provider's payment register.

Want your integrations checked before launch? We run checkout with delivery and payment through real scenarios and return bug reports with video. Order an ecommerce QA audit, from UAH 6,000.

How to test such integrations

A separate list we use as a minimum before launching a store:

  1. Sandbox mode for all providers: test cards, test waybills. Check that test mode did not stay on in production (it happens more often than we would like).
  2. Negative scenarios: bank decline, a closed tab on 3-D Secure, insufficient funds, an expired card.
  3. Repeated callbacks: send the same callback twice by hand (curl or Postman) and make sure nothing doubled.
  4. A forged callback: change the amount or the signature and make sure you get a refusal.
  5. An unavailable API: cut the network to the Nova Poshta API in a test and see what the customer sees.
  6. Different devices: payment and branch selection on iOS Safari and Android Chrome. In-app browsers (Instagram, Telegram) often behave differently.
  7. Automated tests: callback signature, idempotency and cash-on-delivery calculation are easy to cover with Pest/PHPUnit tests, and the full checkout path can be automated with Playwright.

In Laravel it is convenient to fix external API responses through Http::fake(): the test becomes fast and independent of the network, and you check your own logic, not someone else's server. More on automating key paths is on the test automation page.

We wrote separately about why orders get lost even before payment: 7 common checkout bugs. And the full list of pre-release checks is collected in the ecommerce testing checklist.

Monitoring after launch

Integration does not end with the release. External APIs change, keys expire, request limits run out. The minimum worth setting up:

  • a Telegram or email alert when the waybill creation job fails after all retries;
  • a daily report: the number of orders without a waybill and the number of payments without a confirmed callback;
  • logging of requests and responses to the payment provider and Nova Poshta (without full card numbers and private keys) linked to the order number;
  • reminders about the expiry of keys and tokens.

These are small things, but they are exactly what turns "we found out from a customer" into "we fixed it before the customer noticed".

Summary: what to check today

If the store already works, there are five quick checks:

  • Is the branch directory updated automatically, and when was the last time?
  • Is the payment status changed only by the server callback, and is the signature verified?
  • What happens if the same callback arrives twice?
  • Can the manager see the waybill creation error, or is it only in the logs?
  • Does the cash-on-delivery amount match the remainder to pay when there are discounts?

If the answer to at least one question is "we don't know", that is a reason to check. These are the places we go through in a code audit when the store is already live and you need to understand where money is being lost.

The store works, but orders get lost? Describe the problem in the brief and we will tell you where to start checking, free and with no obligation. Fill in the brief.

AUTHORЄвгенійFounder and tech lead of Tochno. 11 years in PHP / Laravel, dozens of online store audits. Writes about what he sees in real projects.About the team

Quick answers

Didn't find an answer? Message us on Telegram.

Why are Nova Poshta branches not loading on my site?

Most often the branch directory is not being refreshed, or the request to the Nova Poshta API fails without error handling. Keep a local copy of the directory and update it on a schedule, and show a fallback way to choose a branch when the API is down. Also check that your API key is still valid.

Why did the payment go through but the order stayed unpaid?

The payment system reports the result in a separate callback request, and if it is not accepted or the signature is not verified, the status will not update. The callback must be idempotent: a repeated call should not create a duplicate. Regularly reconcile orders with the payment provider statements.

How do I avoid duplicate Nova Poshta waybills (TTN)?

Before creating a waybill, check whether one already exists for the order, and block repeated requests on the server. Save the TTN number and status immediately after the API responds. That way a double click or a re-run of a queued job will not create a second waybill.

How can I test a payment integration without real money?

Use the payment provider's test mode: most providers offer test cards and a sandbox for successful and declined payments. Separately check a failure, a delay and a repeated callback. Before launch, make one real payment for a minimal amount to see how the site behaves in real conditions.

Recognise your project?

Describe your task in the brief — we'll reply with an estimate and a plan within one business day.

What you need

By submitting the form you agree to the privacy policy.