Contents
Developers Changelog
What changed, and when
Security fixes lead and say what the defect could actually do: deciding whether tonight is the night to upgrade needs the failure mode, not a reassurance. The v1 REST contract is frozen; anything breaking would ship as v2 alongside it.
API v1
APIA Stripe card payment that needs customer authentication is declined with words that say so, and is neither retried nor cancelled.
Changes
stripe_requires_action: the customer must authenticate
When Stripe answers a server-to-server create with a PaymentIntent in
requires_action(the card issuer wants 3D Secure or similar), the payment isDECLINEDwithdecline_category: authentication_failedand the raw codestripe_requires_action, as before. Stripe did not report it authorized or succeeded, so it was not charged. What changed is what it says: the documentation and the transaction page now state that the customer must complete authentication, which a server-to-server payment cannot show, and that Payshen does not retry it on another provider and does not cancel the provider's payment. Ask the customer to pay again through a checkout where they can authenticate.
API v1
APICapture and void answer their own error code when the provider did not answer, so a refusal and an unknown result no longer look alike.
Changes
capture_result_unknown and void_result_unknown
A capture or void whose provider did not answer (a 5xx, a timeout, a dropped connection, an answer that cannot be read) now answers
502 capture_result_unknownor502 void_result_unknown. The provider may have executed it: the payment isUNKNOWN, do not retry, and the sameIdempotency-Keyreplays this answer while Payshen settles the payment from the provider's record.502 psp_erroron these endpoints now means only that the provider refused: nothing was executed, the payment isAUTHORIZEDagain, and the key is released. Before, both cases answeredpsp_errorand only the message told them apart. If you switch onpsp_errorfor capture or void, add the two new codes and treat them as "do not retry".
API v1
APIIdempotency-Key is required on POST /v1/payments with a production (ps_live_) key, and a retry under the same key does not reach a provider again once one may have received the payment.
Security
A retried payment create could be charged twice
When
POST /v1/paymentsfailed after the provider had been called (the payment could not be recorded, or its answer could not be stored), theIdempotency-Keywas released and a retry under the same key routed the payment again. A request whose process died mid-call was routed again after 15 minutes, and in automatic routing the second run could pick a different connection, where the provider's own idempotency key protects nothing. The customer could be charged twice. Now every provider attempt is recorded against the key before the call, and once one is, a retry is answered from what was recorded: the stored response, else the payment's current state, elseUNKNOWNwith the payment put in front of Payshen operations. It is not sent to a provider again while the key is kept: 24 hours after its answer, and for as long as the attempt has no answer or its payment isUNKNOWN. After that the same key is a new request. A request that died and was never retried is turned into anUNKNOWNpayment by a daily job.
Changes
Idempotency-Key required with production keys
A
POST /v1/paymentswith an API key whose environment is production (aps_live_key) and noIdempotency-Keyis refused400 idempotency_key_required. Sandbox keys (ps_test_) are unchanged. The SDK already sends a key on every create, so SDK users need do nothing; raw HTTP callers must send one unique key per payment and reuse it on retry.500 payment_result_unknown
A provider may have received the payment and its result could not be recorded yet. The error carries
request_id. Retry only with the same key, never with a new one: the retry never charges again, answers409for up to 15 minutes, then the payment as recorded (oftenUNKNOWN).
API v1
APIrefunded_amount now counts only refunds the provider confirmed, a new refund_pending_amount holds the rest, and a refund Idempotency-Key names the refund itself.
Security
A refund that failed at the provider with a 5xx was reported as refused
On a Stripe connection, a refund that Stripe answered with a 5xx was read as a refusal. The API answered
502, the amount became refundable again, and theIdempotency-Keywas released. Stripe may execute a refund and still fail to answer, so the customer could have been refunded while the payment said they were not, and a later refund of a different amount could refund them beyond what the payment recorded. A 5xx, a dropped connection or an answer that cannot be read is now an unknown result: the amount stays reserved and is resolved from the provider's own refund list, and nothing retries it.refunded_amount could count a refund that had not happened
refunded_amount, and theREFUNDEDandPARTIALLY_REFUNDEDstatuses, included refunds still at the provider and refunds whose answer never came. A payment could readREFUNDEDfor money that had not moved. Both now count only refunds the provider confirmed.
Changes
refund_pending_amount on every payment
Minor units reserved for refunds sent to the provider and not confirmed yet, including one whose answer never came. It is on
GET /v1/payments/{id}, the list, every capture, void and refund response, and the dashboard CSV export. It is not refunded, and it cannot be refunded again until it is resolved. A payment can readCAPTUREDwithrefunded_amount: 0andrefund_pending_amount: 5000; that is a refund waiting on the provider, not a failed one.The Idempotency-Key on a refund names the refund
A request to
POST /v1/payments/{id}/refundsunder a key already used on that payment is answered from the refund the key names and never reaches the provider again: the stored success, the stored refusal, or409 refund_pendingwhile the provider has not confirmed it. This holds after the stored response expires. The same key with a different amount answers422 idempotency_key_reuse. A502or500refund answer is kept under its key rather than released, so retrying it returns the same answer. Without a key, every request is a new refund, as before. Capture and void keep the old rule (a 5xx releases the key) for now.refund_result_unknown: an unknown refund result has its own code
A refund whose provider did not answer (a 5xx, a dropped connection, an answer that cannot be read) now answers
502 refund_result_unknown. Do not retry it under any key: the provider may have refunded, the amount stays inrefund_pending_amount, and Payshen settles it from the provider's record.502 psp_erroron this endpoint now means only that the provider refused: nothing was refunded, the same key answers the same refusal, and a retry under a newIdempotency-Keyis a new refund and is safe. Before, both cases answeredpsp_errorand the documentation told you not to retry either.A refund the provider accepted and has not finished is not reported as refunded
On a Stripe connection, a refund Stripe answered with status
pendingorrequires_actionwas reported as refunded. It now answers409 refund_pending, its amount is inrefund_pending_amount, and it counts as refunded only once Stripe sayssucceeded. A refund Stripe answersfailedorcanceledis a refusal. An answer with no refund id or no known status isrefund_result_unknown. Payshen asks Stripe about accepted refunds once a day and settles them; you receivepayment.refundedwhen one is confirmed.How long a refund Idempotency-Key replays
For 24 hours a repeated request replays the stored response exactly. After that, the same key on the same payment is answered from the refund's current record, so a refund that was pending when you first asked answers its final result.
payment.refund_failed: a pending refund that did not go through
A new webhook event. When a refund held in
refund_pending_amount(the provider accepted it and had not finished, or never answered) is settled as not refunded, from the provider's answer or by Payshen operations from the provider's record, you receivepayment.refund_failedwithrefund_id,refund_amount,refunded_total,refund_pending_amountandfailure_reason. The amount is no longer reserved and was not refunded. Endpoints subscribed to all events receive it; an endpoint with an event list receives it once you add it. A refund the provider refuses at once is still answered on the request itself and sends no event.409 refund_pending when only a pending refund is left
When the whole remaining balance is held by a refund the provider has not confirmed, a new refund answers
409 refund_pendinginstead of422 invalid_amountwith an impossible range. Do not retry it; it clears when the pending refund is resolved.
API v1
APIA new status, UNKNOWN: the provider may have executed and we could not tell. Treat it like PENDING, never like DECLINED.
Security
A capture or void whose answer was lost invited a second attempt
When a provider timed out after we had sent a capture or a void, or the connection dropped mid-answer, we reported it as failed at the provider and put the payment back to
AUTHORIZED. That was a guess, and the wrong one to guess: the provider may have executed the capture and simply not managed to tell us, and an authorized payment invites a second capture for the same order. No attacker is needed for this - a slow provider on a busy evening is enough. Such a capture or void now endsUNKNOWNand is not retried.Still open: a timeout on the initial payment request
Still open: a timeout on the initial payment request is reported as
DECLINEDtoday, and another provider may be tried. We are changing this so it endsUNKNOWNand is never retried elsewhere; until then, treat aDECLINEDwith decline reasonprovider_erroras possibly charged and check with support before charging the customer again.
Changes
UNKNOWN is now a member of the PaymentStatus enum
It means exactly one thing: the provider may have executed the operation and we could not tell. It is in flight, not an outcome, and never a success by default. Today it appears after a capture or a void whose provider call did not answer, and you can read it on
GETand in webhooks, wherever a status can appear. The synchronous branch ofPOST /v1/paymentsdoes not answerUNKNOWNtoday. There is no automatic reconciliation yet: Payshen operations resolve anUNKNOWNpayment by hand from the provider's own record, and it then moves to a confirmed status. A refund whose provider call did not answer keeps its amount reserved and is written on the timeline aspayment.refund_unknown.What to do
Treat
UNKNOWNexactly as you treatPENDING: show "processing", wait for the webhook or poll the payment, and never render it as declined or repeat the capture, void or refund yourself. Resolving it is our job, by hand for now, against the provider's record. If your code switches on the status with a default branch that means "anything else is a failure", that branch is now wrong; add the case. The OpenAPI document carries the value and this description.Why this is additive and not a v1 break
v1 promised that no value would be removed or change meaning, and that new ones may be added, so parse defensively. This is the first time that promise is exercised on an enum. Nothing that existed moved; one value was added, on 14 September 2026, and a client with an exhaustive switch has to add a case. That is the behaviour the freeze was written to allow.
@payshen/sdk 0.4.0 carries the new value in the type
The
PaymentStatusunion gains"UNKNOWN"and nothing else changes. It is a minor version rather than a patch because the union widened: aswitchwith exhaustive checking fails to compile until the new case is handled. That is the point - a status meaning "we do not know whether you were charged" must not fall through to whatever handled the old default, and the compiler is the one reader who never skips a changelog. Prepared alongside this entry; see the SDK guide for the client.
@payshen/sdk 0.3.0
SDKIf you handle webhooks: signature verification never rejected a replay. Deduplicate on event.id - claimWebhookEvent does it for you.
Security
verifyWebhookSignature documented replay rejection and only checked freshness
It said it threw on a replay. It does not, and could not: it checks the signature and that the timestamp is inside the tolerance window, and the identical delivery inside that window verifies every time. No attacker is needed - we retry a delivery that times out or answers 5xx, so a slow endpoint receives the same event twice in ordinary operation. A handler that captures, refunds or fulfils on every verified event would do it twice. Nothing about the verification changed; what changed is that it says what it does.
Changes
claimWebhookEvent and parseWebhookEvent
claimWebhookEventverifies, parses and claims the event id against a store you provide, returning the event the first time andnullwhen it has already been handled. The store has one atomic method,claim- oneINSERT ... ON CONFLICT DO NOTHING- because a separate read and write lets two concurrent deliveries both through.parseWebhookEventverifies and returns the typed event when you want to claim inside your own transaction. See webhooks in the SDK guide.WebhookEvent.created_at was never sent
The field on the wire is
created. Code that trusted the type readundefinedwith the compiler agreeing. The type is corrected, which is why this is a minor version: any code that readcreated_atnow fails to compile, instead of failing quietly at runtime.
API v1
APIPOST /v1/payments now answers the status enum it always documented. Check your branch on it.
Changes
status on the create response was the one field that broke the contract
Creating a payment answered
"approved"- lowercase, and not a member of thePaymentStatusenum our own OpenAPI document declares for that field. Every other endpoint already answered from that enum: reading the same payment a moment later returned"CAPTURED", and so did capture, void and refund. Soif (res.data.status === "CAPTURED")was false for a payment that had captured, and the redirect half of this same endpoint answered"REQUIRES_ACTION"- the two halves did not agree with each other.What changed, and what to do
statusnow carries the documented value, identical topayment_statusbeside it:CAPTURED,AUTHORIZED,DECLINED, and the rest of the enum. If you compared against"approved"or"authorized"on the create response, compare against the uppercase value - or readpayment_status, which has always been correct and is unchanged. Nothing else on the response moved.Why this is a correction and not a v1 break
v1 is frozen against changes of meaning, and the meaning here did not change - the published contract already said this field is a
PaymentStatus. What changed is that the endpoint now keeps it. A generated client built from the spec was already broken against the old behaviour; one written by hand against observed behaviour needs the one-line change above.
@payshen/sdk 0.2.0
SDKUpgrade from 0.1.0 if you take payments - the first fix prevents a double charge.
Security
A retried payment could charge twice
The client retries transient failures, and a connection dropped after the server created a payment is indistinguishable from one dropped before it. Without an idempotency key the retry created a second, unrelated charge. Every
POSTnow carries one - yours, or one generated by the client - reused across every attempt of that call.A plain-http base URL is refused
The API key travels in a header on every request. Over plain HTTP that header is readable by anything on the path, so a misconfigured base URL leaked live credentials silently.
http://localhoststays allowed, because that is where mock servers run.The published webhook example was unsafe
Our documentation showed
verifyWebhookSignaturecalled with positional arguments and its result tested withif (!ok). The real function takes an options object, is async, and throws - so the snippet returned a Promise, which is always truthy, the check never fired, and every forged webhook was accepted. It also rejected with nobody listening, which terminates a Node 18+ process. The site is corrected and a test now guards that snippet wherever it is published. If you copied it, replace it with the form on the SDK page.
Changes
A reused AbortSignal no longer accumulates listeners
One listener was added per retry attempt and never removed, which on a long-running process reached Node's max-listeners warning and held memory that should have been released.
@payshen/sdk 0.1.0
SDKFirst published release. Superseded by 0.2.0 - it can charge twice on a dropped connection.
Changes
The client
Payments (create, retrieve, list, capture, void, refund), the hosted flow, events, the partner endpoints, webhook verification, typed errors, bounded retries with jitter, timeouts and
AbortSignal. Zero dependencies; ESM, CommonJS and types.
API v1
APIv1 is frozen.
Changes
The contract will not move underneath you
Paths, request fields, response fields and error codes will not be removed or change meaning within v1. New optional fields and new endpoints may be added, so parse defensively and ignore what you do not recognise. Anything breaking ships as
/api/v2alongside this, never in place of it.The /v1/transactions aliases were removed
They duplicated
/v1/paymentswith a different status filter, which is the kind of ambiguity that becomes permanent once a contract is frozen. Removed before the freeze rather than carried forever. Use/api/v1/payments.The contract is published
OpenAPI 3.1, unauthenticated, so it can be imported into Postman or used to generate a client without an account.
