verify-webhook-signature endpoint that really verifies. The approve link of an order opens a hosted approval page where a seeded sandbox buyer approves or cancels and goes back to your return_url or cancel_url. Errors use PayPal’s envelope (name, message, debug_id, details[].issue, links), and responses are checked against PayPal’s published OpenAPI documents. The tests drive it with @paypal/paypal-server-sdk (2.5.0).
At a glance
Start
--service. When it is the only enabled service, it listens on http://localhost:4000. Without a seed config there is one app (client id baseline-paypal-client-id, secret baseline-paypal-client-secret) and one sandbox buyer ([email protected]).
@paypal/paypal-server-sdk always calls api-m.paypal.com or api-m.sandbox.paypal.com. Point it at the emulator with axios’ fetch adapter and a fetch that swaps the origin:
URL mapping
Auth
POST /v1/oauth2/token— Get access token: HTTP Basicclient_id:client_secretandgrant_type=client_credentials(form or JSON)POST /v1/oauth2/token/terminate— Revoke access token:token=<access token>
scope, access_token, token_type: Bearer, app_id, expires_in (32400, nine hours), and nonce. As on PayPal, the same token comes back while it is valid, with the seconds it has left. An optional scope narrows the token; a scope the app does not have answers invalid_scope. Terminating a token revokes it.
- A wrong client id or secret answers 401
{"error":"invalid_client","error_description":"Client Authentication failed"}. - A missing grant type answers 400
unsupported_grant_type(Grant Type is NULL). - An API call without a bearer token answers 401
AUTHENTICATION_FAILURE. An unknown or revoked token answers 401{"error":"invalid_token","error_description":"Token signature verification failed"}, and an expired oneAccess Token expired. - A token without a scope the call needs answers 403
NOT_AUTHORIZEDwithPERMISSION_DENIED, as does reading another app’s order or payment. - A bearer token the Baseline CLI was started with acts as the first app with all of its scopes.
Orders v2
POST /v2/checkout/orders— Create order:intentCAPTUREorAUTHORIZEand one to tenpurchase_units. Amounts are checked as PayPal does: value syntax, currency decimals (JPY,HUF, andTWDtake none),amount.breakdownagainst the total (AMOUNT_MISMATCH), items againstitem_totalandtax_total(ITEM_TOTAL_MISMATCH,TAX_TOTAL_MISMATCH), one currency per unit, uniquereference_ids when there are several units.payment_source.paypal(withexperience_context:return_url,cancel_url,brand_name,user_action,shipping_preference) makes the orderPAYER_ACTION_REQUIREDwith apayer-actionlink. Without a payment source the order isCREATEDwith anapprovelink;application_contextis read for the same fields.payment_source.card(number,expiry,name) makes the orderAPPROVEDat once.attributes.verification.methodofSCA_ALWAYSasks for 3D Secure: the order isPAYER_ACTION_REQUIREDuntil the buyer completes the step on the hosted page. Expired cards answerCARD_EXPIRED. Other sources answerPAYMENT_SOURCE_NOT_SUPPORTED.GET /v2/checkout/orders/{id}— Show order detailsPATCH /v2/checkout/orders/{id}— Update order: a JSON Patch array (add,replace,remove) on/intent,/payer, and purchase unit fields addressed by reference id, such as/purchase_units/@reference_id=='default'/amount,.../shipping/address,.../invoice_id,.../custom_id,.../description,.../items. Other paths answerNOT_PATCHABLE; malformed pointersINVALID_JSON_POINTER_FORMAT. Answers 204.POST /v2/checkout/orders/{id}/confirm-payment-source— Confirm payment source: sets a paypal or card source on an orderPOST /v2/checkout/orders/{id}/authorize— Authorize payment for order: this and/captureanswer 201 and refuse withORDER_NOT_APPROVED,ACTION_DOES_NOT_MATCH_INTENT,ORDER_ALREADY_CAPTURED,ORDER_ALREADY_AUTHORIZED,PAYER_ACTION_REQUIRED, orDUPLICATE_INVOICE_ID(another payment of the app already used the invoice id).processing_instruction: ORDER_COMPLETE_ON_PAYMENT_APPROVALcaptures or authorizes on approval.
CREATED, PAYER_ACTION_REQUIRED, APPROVED, COMPLETED, VOIDED. SAVED (vaulting with the order) and order tracking are not modeled.
Every write takes PayPal-Request-Id: a retry with the same id gets the first answer back for 72 hours, and a failed call is not kept, so it can be retried. Prefer: return=minimal (the default) answers id, status, and links; return=representation the whole resource. Links are HATEOAS links (self, approve or payer-action, update, capture or authorize).
Hosted approval page
Theapprove or payer-action link opens /checkoutnow?token={order id}. It shows the order total and every seeded sandbox buyer. Picking one approves the order (shipping comes from the buyer’s address unless the order has one or shipping_preference is NO_SHIPPING) and redirects to return_url?token={order id}&PayerID={payer id}. “Cancel” redirects to cancel_url?token={order id} and leaves the order as it was. For a card that needs 3D Secure, the page shows the authentication step instead, and the order’s payment_source.card.authentication_result reports liability_shift: POSSIBLE afterwards. Without a return_url, the page says the payment is approved.
Payments v2
GET /v2/payments/authorizations/{id}— Show authorized payment details.POST .../capturecaptures all of an authorization or part of it (amount,final_capture), up to ten times, never more than is left (MAX_CAPTURE_AMOUNT_EXCEEDED). Statuses goCREATED,PARTIALLY_CAPTURED,CAPTURED.POST .../reauthorizeis refused during the three day honor period (REAUTHORIZATION_TOO_SOON) and allows up to 115% of the original amount (at most 75 more in the currency); it returns a new authorization and voids the original. Authorizations expire after 29 days (AUTHORIZATION_EXPIRED).POST .../voidanswers 204 (or 200 withPrefer: return=representation). Voiding the last open authorization of an order with no captures voids the order.GET /v2/payments/captures/{id}— Show captured payment details.POST .../refundrefunds it, with an optionalamount(default: what is left). Refunds may be partial; more than is left answersREFUND_AMOUNT_EXCEEDED, a fully refunded captureCAPTURE_FULLY_REFUNDED, a capture older than 180 daysREFUND_TIME_LIMIT_EXCEEDED. Capture statuses goCOMPLETED,PARTIALLY_REFUNDED,REFUNDED.GET /v2/payments/refunds/{id}— Show refund details
seller_receivable_breakdown with a PayPal fee of 3.49% plus a fixed fee per currency (0.49 USD); refunds carry seller_payable_breakdown with total_refunded_amount.
Emulator clock
POST /_paypal/clock/advance— Advance emulator clock:{"seconds": 259200}
seconds, past honor periods, authorization expiry, the refund window, and token expiry. The clock is saved with the state. SDKs time their cached token by the wall clock, so after jumping past nine hours create a new client to fetch a new token.
Deterministic failures
- Negative testing: a
PayPal-Mock-Response: {"mock_application_codes":"INSTRUMENT_DECLINED"}header makes the call fail with that issue and changes nothing, as in PayPal’s sandbox. Most codes answer 422UNPROCESSABLE_ENTITY;INTERNAL_SERVER_ERRORanswers 500,PERMISSION_DENIED403,INVALID_RESOURCE_ID404, and parameter issues 400.INSTRUMENT_DECLINEDon an order adds theredirectlink back to the approval page. - Sandbox buyers: a buyer seeded with
fail_with: INSTRUMENT_DECLINED(or any issue) fails every authorize or capture.INSTRUMENT_DECLINEDsends the order back toCREATEDwith aredirectlink, so the buyer can pick another account. A buyer withcapture_status: PENDINGgetsPENDINGcaptures (status_details.reason: PENDING_REVIEW). - Card names: a card whose
nameis one of PayPal’s test triggers is declined: capturing gives aDECLINEDcapture and authorizing aDENIEDauthorization, with its processor response code:CCREJECT-REFUSED(0500),CCREJECT-SF(9500),CCREJECT-EC(5400),CCREJECT-IRC(5180),CCREJECT-IF(5120),CCREJECT-LS(9520),CCREJECT-IA(1330),CCREJECT-BANK_ERROR(5100),CCREJECT-CVV_F(00N7). - Fault injection: the
faults:seed key and the control API add latency or canned responses in front of any route, as for every Baseline service.
Webhooks
Webhooks come fromapps[].webhooks in the seed config or the webhooks API:
POST /v1/notifications/webhooks— Create webhook:url,event_types, up to 10 per appDELETE /v1/notifications/webhooks/{id}— Delete webhook: the same path also takesGET, andPATCHto replace/urlor/event_typesGET /v1/notifications/webhooks/{id}/event-types— List event subscriptions for webhookGET /v1/notifications/webhooks-event-types— List available eventsGET /v1/notifications/webhooks-events— List event notifications: newest first,page_size,start_time,end_time,event_type,transaction_id,nextlinksPOST /v1/notifications/simulate-event— Simulate webhook event: sends a sample event of a type to a webhook or URL without keeping itPOST /v1/notifications/verify-webhook-signature— Verify webhook signature
VALIDATION_ERROR, INVALID_RESOURCE_ID, WEBHOOK_URL_ALREADY_EXISTS, WEBHOOK_NUMBER_LIMIT_EXCEEDED. Plain http:// URLs are accepted so local servers can listen, unlike PayPal.
Events sent: CHECKOUT.ORDER.APPROVED, CHECKOUT.ORDER.COMPLETED, CHECKOUT.ORDER.VOIDED, PAYMENT.AUTHORIZATION.CREATED, PAYMENT.AUTHORIZATION.VOIDED, PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.PENDING, PAYMENT.CAPTURE.DECLINED, PAYMENT.CAPTURE.REFUNDED. Each is an event (id, event_version, create_time, resource_type, resource_version, event_type, summary, resource, links) kept for the events list, GET .../webhooks-events/{id}, and POST .../webhooks-events/{id}/resend. Failed deliveries are retried 8 times with backoff.
Deliveries carry PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-TIME, PAYPAL-TRANSMISSION-SIG, PAYPAL-CERT-URL, and PAYPAL-AUTH-ALGO: SHA256withRSA. The signature is RSA SHA-256 over transmission_id|transmission_time|webhook_id|crc32(raw body), with the CRC32 as an unsigned decimal. Verify it either way:
- The verify endpoint takes
auth_algo,cert_url,transmission_id,transmission_sig,transmission_time,webhook_id, andwebhook_eventand answers{"verification_status":"SUCCESS"}orFAILURE. - Offline, fetch the PEM certificate at
PAYPAL-CERT-URL(served by the emulator at/v1/notifications/certs/{id}, one key per emulator, kept in saved state) and check the signature with it, as PayPal’s own samples do:
Seed config
brand_name: shown on the approval pagemerchant_email,merchant_id, andscopesare optional on an app
email, name or given_name and surname, payer_id (13 characters from 2-9 and A-Z without I or O), country_code, address, phone, fail_with, and capture_status.
Inspector
/_inspector shows orders, captures, authorizations, refunds, webhooks, events, deliveries, apps, sandbox buyers, and the emulator clock.
Limits
- No order tracking, and no
SAVEDstatus or vaulting. - No wallets or alternative payment methods beyond
paypalandcardpayment sources. - Subscriptions, Invoicing, Payouts, and Disputes are not modeled.
Conformance
See CONFORMANCE.md for every provider.