Skip to main content

v2 to v3 Migration Guide

This guide covers what changed between v2 (@zkp2p/pay-sdk@2.0.0 / @zkp2p/pay-shared@2.0.0) and v3 (@zkp2p/pay-sdk@3.0.0 / @zkp2p/pay-shared@3.0.0).

Required SDK Upgrade

npm install @zkp2p/pay-sdk@3.0.0 @zkp2p/pay-shared@3.0.0

Summary

Both packages have breaking changes. Most are in @zkp2p/pay-shared, but @zkp2p/pay-sdk consumers are affected too — the SDK re-exports several shared types, and its embedded event union gained a member. Its @zkp2p/pay-shared dependency is now ^3.0.0.

Every break in v3 is compile-time. Upgrade both packages together and typecheck; TypeScript will point at each site that needs attention.

Breaking Changes — @zkp2p/pay-sdk

These affect you even if you never import @zkp2p/pay-shared directly.

EmbedCheckoutEventType gained checkout.closed

If you exhaustively narrow the embedded event union — a switch with a default that assigns to never, or an exhaustiveness helper — it no longer compiles, because 'checkout.closed' is not never:

function handle(type: EmbedCheckoutEventType) {
switch (type) {
case 'checkout.success': return onSuccess();
case 'checkout.failed': return onFailed();
case 'checkout.closed': return onClosed(); // v3: add this arm
default: {
const _exhaustive: never = type; // v2 compiled; v3 errors without the arm above
return _exhaustive;
}
}
}

Non-exhaustive if chains keep compiling — but they will silently ignore checkout.closed, leaving the customer stuck in an iframe that never closes. See Dismissing the iframe.

getMerchant(merchantId, options) overload removed

getMerchant now takes a single argument:

// v2 — the two-argument form silently ignored merchantId
const m = await getMerchant(merchantId, { apiBaseUrl, apiKey });

// v3
const m = await getMerchant({ apiBaseUrl, apiKey });

This is not a new restriction. The merchant has always been determined by the API key, and the extra merchantId was discarded — so v2 code passing one was already getting the API key's merchant, not the one it named. The v0→v1 guide documented the overload as removed; it survived in code until now. If you were relying on it to fetch a different merchant, it never did that.

Response aliases removed

Three aliases are gone. They collided by name across the two packages while resolving to different shapes, which made CheckoutCreateResponse mean different things depending on where you imported it from.

RemovedFromReplace with
CheckoutCreateResponse@zkp2p/pay-sdkCreateCheckoutResult
CheckoutCreateResponse@zkp2p/pay-sharedCreateOrderResponse
CreateCheckoutResponse@zkp2p/pay-sharedCreateOrderResponse

Non-enveloped API responses are rejected

The SDK previously accepted a raw JSON body with no success property, and fell back to returning the whole envelope when it carried no responseObject. Both paths are gone — the SDK now requires a well-formed { success, message, responseObject, statusCode } envelope and throws otherwise.

The Pay API has always sent that envelope on every endpoint the SDK calls, so this changes nothing against a real API. It matters only if you point the SDK at a mock, proxy, or fixture that returns bare JSON — those now throw Malformed API response envelope instead of silently handing back a wrongly typed object.

MerchantProfile fields changed

MerchantProfile (also re-exported as MerchantInfo, and returned by getMerchant) dropped migrationStatus and added three required fields: integrationPath, onboardingCompletedAt, onboardingSkippedAt.

Reading profile.migrationStatus no longer compiles. Any code that constructs a MerchantProfile — test fixtures, mocks, fakes — must supply the three new fields.

Breaking Changes — @zkp2p/pay-shared

CreateWebhookResponse is now nested

The webhook creation response changed from a flat object to a nested one. In v2 it was Webhook & { secret }, so the webhook fields sat at the top level:

// v2
const res: CreateWebhookResponse = await createWebhook(...);
res.id; // webhook id
res.url; // webhook url
res.events; // subscribed events
res.secret; // signing secret

In v3 the webhook is a nested property:

// v3
const res: CreateWebhookResponse = await createWebhook(...);
res.webhook.id;
res.webhook.url;
res.webhook.events;
res.secret; // unchanged — still top level

Only secret kept its position. Every other field moved under webhook.

WebhookWithSecret removed

WebhookWithSecret was the alias backing the old flat CreateWebhookResponse. It no longer exists. Use CreateWebhookResponse in its new nested shape, or compose it yourself:

// v2
import type { WebhookWithSecret } from '@zkp2p/pay-shared';

// v3
import type { Webhook, CreateWebhookResponse } from '@zkp2p/pay-shared';
type WebhookWithSecret = { webhook: Webhook; secret: string };

Legacy merchant-migration exports removed

These were internal to the retired merchant-migration app and the dual-Privy setup, both of which have been removed. They have no v3 replacement — delete any references:

  • DashboardPrivyApp
  • DashboardPrivyAppType
  • MerchantMigrationStatus
  • MerchantMigrationStatusType

New in v3

The checkout.closed event itself

Covered as a breaking change above for its effect on exhaustive unions, but the behavior behind it is new. When an embedded checkout has no payable option — for example no payment rail has liquidity for the amount — it shows a Go Back button that posts:

{
"channel": "zkp2p_checkout_embed_v1",
"type": "checkout.closed",
"payload": { "order_id": "...", "reason": "no_payment_methods" }
}

Handle it by closing the iframe. It is a dismissal rather than a failure, and the order can be reopened with the same checkout URL. If you do not handle it, the Go Back button appears to do nothing and the customer stays stuck in the iframe. See Integration Options for the full listener.

Not a "zero funds received" signal

checkout.closed does not guarantee nothing was charged. A partially paid order returns to method selection to pay its remaining balance, and if no rail has liquidity for that remainder it can emit checkout.closed too. Check the authoritative order state by order_id or from your webhook stream before telling a customer nothing was taken.

Purely additive

Nothing in this section requires a change to existing code.

Dynamic order resize types

CheckoutNearbySuggestion and CheckoutNearbySuggestions are now re-exported from @zkp2p/pay-sdk, describing the nearby-amount suggestions offered to dynamic-orders merchants when an exact amount has no liquidity.

Additional shared utilities

@zkp2p/pay-shared also gained address validators (isValidSolanaAddress, isValidTronAddress, base58 helpers), merchant onboarding step types, and payee referral fee-cap helpers.

Upgrade Checklist

  1. Install @zkp2p/pay-sdk@3.0.0 and @zkp2p/pay-shared@3.0.0 together.
  2. Add a checkout.closed arm to any exhaustive switch over EmbedCheckoutEventType, and a handler that closes the iframe if you embed checkout. A non-exhaustive if chain still compiles but silently strands the customer.
  3. Drop the first argument from any getMerchant(merchantId, options) call.
  4. Replace CheckoutCreateResponse / CreateCheckoutResponse with CreateCheckoutResult (SDK) or CreateOrderResponse (shared).
  5. If you point the SDK at a mock or proxy, make sure it returns the full { success, message, responseObject, statusCode } envelope.
  6. Replace reads of MerchantProfile.migrationStatus, and add integrationPath, onboardingCompletedAt and onboardingSkippedAt to any MerchantProfile fixtures or mocks you construct.
  7. Search your codebase for WebhookWithSecret and replace it.
  8. Update webhook-creation handling to read response.webhook.* instead of response.*, keeping response.secret as is.
  9. Delete any references to DashboardPrivyApp or MerchantMigrationStatus.
  10. Typecheck. Every breaking change in v3 surfaces at compile time.