Set up delegated refunds

Ask about this Page
Copy for LLM
View as Markdown

Understand how InStore delegates returns and refunds to your integration, and implement the webhook notifications your integration receives.

Set up delegated refunds

Setting up delegated refunds is similar to setting up delegated payments.
You do not need to implement delegated payments to implement delegated refunds.
When InStore receives a delegated refund request, it records the tender against the session. For every tender your processor handles, it does not create or reverse a commercetools Payment transaction on your behalf. Your payment processor integration remains responsible for reversing the original charge. Cash tenders are the exception. For the processor endpoint and payload, see Refund webhook.

InStore provides for delegation of these refund types:

Cash tenders can be included in a delegated refund plan, but they are handled differently from other tender types.

A delegated refund moves through four objects in your systems:

ObjectDirection
Refund objectYour host application to InStore
Refund requestInStore to your payment processor
Finalize notificationInStore to your webhook service
Receipt data requestInStore to your webhook service

How to read this page

Every field table on this page has a Presence column. What it means depends on the direction of the payload, which is stated at the start of each section: some payloads you send to InStore, and some InStore sends you.
  • Required: the field is always present. In a payload you send to InStore, you must include it or InStore rejects the request. In a payload InStore sends you, InStore always includes it.
  • Optional: the field can be absent. In a payload you send to InStore, you can omit it. In a payload InStore sends you, InStore only includes it under the conditions described. Absent fields are omitted from the JSON entirely rather than sent as null, so write your handlers so that a missing key and an empty value behave the same way.

Money and reference shapes

Three different money shapes appear during a refund. Which one you get depends on where the value originated, not on the endpoint, so check the shape per field rather than per payload.

ShapeStructureUsed by
Refund instruction money{ amount, currency }refundAmount in the refund object, which your host application sends to InStore.
InStore tender money{ amount, currency, precision }data.tenderItems[].amount in the receipt data request, which InStore sends to you. precision is the number of decimal digits.
REST API money{ currencyCode, centAmount, fractionDigits }Everything InStore sends to you: to your payment processor and in the finalize refund webhook. InStore is the sender, but the shape matches the REST API CentPrecisionMoney type.

Two different reference shapes also appear:

ShapeStructureUsed by
REST API reference{ typeId, key } or { typeId, id }References to commercetools resources and to InStore payment options. The shape matches a REST API Reference or ResourceIdentifier. Send key or id; you can send both. Where InStore resolves a reference, it prefers key.
Scope reference{ type, key }tenant, location, and workstation in the finalize refund webhook only. This shape is specific to InStore, not the REST API.

Refund object

When a store associate starts a return, your host application passes a refund object to the InStore refund component, in the format shown below. It describes the return Cart, the return Orders and Line Items, and one or more refund instructions. Each refund instruction names a payment option and the original commercetools Payment it refunds. It can also name fallback payment options to try when the primary option cannot complete the refund.

InStore adds the location and workstation, creates the refund session from the object, and resolves the payment options, processors, and totals the associate needs to complete the return.

Sample refund objectjson
{
  "returnCart": { "typeId": "cart", "key": "cart-1" },
  "returnOrders": [
    {
      "order": { "typeId": "order", "key": "order-1" },
      "returnLineItem": [{ "typeId": "line-item", "id": "193eb21f-a5e7-4327-a6c9-705282da9f30" }]
    }
  ],
  "refunds": [
    {
      "refundAmount": { "amount": 4999, "currency": "USD" },
      "paymentOption": { "typeId": "paymentOption", "key": "option-payment-key" },
      "transactionId": "451sd5f1",
      "order": { "typeId": "order", "key": "order-1" },
      "originalCTPayment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" },
      "pspData": {
        "originalPaymentMethodName": "Mastercard 4444",
        "simulateMode": "async",
        "simulatePrompt": true
      },
      "metaData": {
        "prompt": "Refund will be made to original payment",
        "promptInfo": "Mastercard ending 4444: -$50.00"
      },
      "fallbackRefundTypes": [
        {
          "paymentOption": { "typeId": "paymentOption", "key": "option-credit-us-1" },
          "pspData": {},
          "metaData": {
            "prompt": "Refund will be made to original payment",
            "promptInfo": "Mastercard ending 4444: -$50.00"
          },
          "processingSequence": 0.9
        }
      ]
    }
  ],
  "idempotencyKey": "cart-1-attempt-1"
}
FieldData typePresenceDescription
returnCartObjectRequiredA reference to the Cart created based on the items refunded. InStore uses this to calculate totals and to get information for the refund receipt. Without it, InStore can't create the refund session.
returnOrdersArrayRequiredThe Orders that contain the items being returned. Send an empty array if you have none.
returnOrders[].orderObjectRequiredA reference to the Order related to the returned Line Items. Contains typeId, and key or id.
returnOrders[].returnLineItemArrayRequiredThe Line Items from the Order that are being returned. Send an empty array if you don't itemize the return.
refundsArrayRequiredThe refund instructions to be processed. InStore walks them in the order you list them.
refunds[].refundAmountObjectRequiredThe refund amount, as refund instruction money.
refunds[].paymentOptionObjectRequiredThe payment option selected for the refund. Contains typeId, and key or id. InStore resolves it and fails the whole session if it doesn't exist.
refunds[].orderObjectRequiredA reference to the Order associated with this refund.
refunds[].originalCTPaymentObjectRequiredA reference to the original commercetools Payment used for the purchase. InStore fetches it to report the original payment amount, and fails the session if it doesn't exist.
refunds[].transactionIdStringOptionalThe identifier of the original payment transaction to refund against. A commercetools Payment can carry multiple prior refunds, so this tells InStore which transaction to refund. When you omit it, InStore falls back to the originalCTPayment identifier as the originalRefundTransactionId it reports in the finalize refund webhook.
refunds[].pspDataObjectOptionalPayment service provider data related to the refund. Can have any value, with one key InStore reads: originalPaymentMethodName. InStore displays that value as the original payment method, and records it as the payment method on a cash refund tender. The whole object, that key included, is also passed through untouched to your processor as additionalData, for your processor to interpret. In the sample above, simulateMode and simulatePrompt are keys the processor defines: they tell it to answer asynchronously and to raise an operator prompt before completing the refund.
refunds[].metaDataObjectOptionalDisplay metadata used to communicate refund information to the user. When you omit it, the refund screen shows no prompt text.
refunds[].metaData.promptStringOptionalThe main prompt message shown for the refund.
refunds[].metaData.promptInfoStringOptionalAdditional prompt information, such as masked card details and the refund amount. InStore shows it on the refund summary when you send it.
refunds[].customerIdStringOptionalApplies to pay-on-account refunds only. The customer account to credit the refund to. InStore expects it to be optional, so the field is never required and a refund instruction without it is still valid, but a pay-on-account refund can't identify the account to credit without it. Omit it for every other refund type.
refunds[].fallbackRefundTypesArrayOptionalFallback refund options to use if the primary refund option cannot be processed. A refund can use a configured credit option as a fallback.
refunds[].fallbackRefundTypes[].paymentOptionObjectRequiredThe fallback payment option. InStore resolves it at session creation, like the primary one.
refunds[].fallbackRefundTypes[].pspDataObjectOptionalAs refunds[].pspData, for this fallback.
refunds[].fallbackRefundTypes[].metaDataObjectOptionalAs refunds[].metaData, for this fallback.
refunds[].fallbackRefundTypes[].processingSequenceFloat (0-1)OptionalThe intended priority of this fallback option. InStore stores the value with the refund session, but doesn't use it to order the fallbacks: they're offered in the order you list them in fallbackRefundTypes.
refunds[].fallbackRefundTypes[].customerIdStringOptionalAs refunds[].customerId. Send it when the fallback is a pay-on-account option.
idempotencyKeyStringOptionalA key that makes session creation repeatable. When InStore has already created a session for this key on your tenant, it returns that session instead of creating a second one. Send it if the associate can retry a return that failed mid-flight.
A fallback carries no transactionId of its own. It refunds the same Order and commercetools Payment as the refund instruction it replaces, so InStore copies those identifiers down from the parent instruction.

How the refund types differ

Every processor-mediated refund type uses the same path on your processor URL: {processorUrl}/refund. One handler serves all four types.
The main differences are the paymentMethod variant InStore sends, the tender type it records, and whether your processor is set up before the refund starts:
Payment processor typepaymentMethod.typeTender recorded asSetup before the refund
BankCardBankCardCreditinitialize_credit_processor and connect_card_reader, according to the setup steps configured on the processor.
GiftCardGiftCardGiftCardNone.
WalletWalletWalletNone.
PayOnAccountPayOnAccountPOANone.
Apart from these differences, the request body is identical across the four types. You can route a refund on paymentMethod.type alone, without resolving the payment option first.
Cash isn't in this table: it reaches no processor, so it has no paymentMethod variant. It's recorded as a Cash tender and reported to you in the finalize and receipt data payloads only.

Credit refund processing

The delegated refunds feature supports refunding to and from credit cards, debit cards, and other tenders that are of the BankCard tender type, including stored value cards that are processed through a PED. This is the only type whose processor is initialized and connected to a PED before the refund starts. For those setup calls, see Processor webhook paths that we provide for specific payment types.
The refund itself goes to your refund path, and the tender is recorded as Credit.

Configure the Cancel button

For delegated refunds that involve a Credit tender, you can configure a Cancel button for display during the credit stepper. The store associate can cancel a refund that has not completed. When selected, the button triggers the cancel_credit_payment webhook path, the same route used to cancel payments.
For configuration details, including the required receipt template, see the allowCancel property in Build payment components.

Stored value refund processing

The delegated refunds feature supports refunding to and from stored value cards that are of the GiftCard tender type. The refund goes to your refund path with paymentMethod: { "type": "GiftCard" }, and the tender is recorded as GiftCard.
Identify the card from referencePayment and your own records of the original payment.

Wallet refund processing

The delegated refunds feature supports refunding to digital wallets that are of the Wallet tender type. The refund goes to your refund path with paymentMethod: { "type": "Wallet" }, and the tender is recorded as Wallet.
Identify the wallet from referencePayment and your own records of the original payment.

Pay-on-account refund processing

The delegated refunds feature supports refunding to customer accounts that are of the PayOnAccount tender type. The refund goes to your refund path with paymentMethod: { "type": "PayOnAccount", "customerId" }, and the tender is recorded as POA.
Pay-on-account is the only refund type that uses customerId. Send it on the refund instruction, or on the fallback when the fallback is the pay-on-account option:
Sample pay-on-account refund instructionjson
{
  "refundAmount": { "amount": 2000, "currency": "USD" },
  "paymentOption": { "typeId": "paymentOption", "key": "option-poa-us-1" },
  "order": { "typeId": "order", "key": "order-1" },
  "originalCTPayment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" },
  "customerId": "cust-8842"
}
customerId is optional everywhere it appears in the contract, and InStore never rejects a refund for omitting it. What it does with it, when you send it:
  1. Sends the customerId from the refund instruction being processed, or from the fallback when a fallback is running, to your processor as paymentMethod.customerId. Only the PayOnAccount variant carries the field.
  2. Stores the first customerId it finds across the refunds and their fallbacks on the refund session.
  3. Reports that stored value back to you as tenderInformation.customerId in the finalize refund webhook, on POA tenders only.

These are two different lookups. The value your processor receives belongs to the instruction being processed; the value the webhook reports is the first one found anywhere on the session. Send the same account on every instruction in a return to keep them aligned.

Because the field is optional, a pay-on-account refund instruction that omits customerId reaches your processor with no account to credit. Always send it on pay-on-account refunds, and reject the request in your processor when it's missing rather than crediting a default account.

Cash refund processing

Cash tenders use a separate refund process, and it's the one refund type your payment processor isn't involved in. The store associate pays the refund out of the drawer configured for the workstation in InStore, and InStore records the tender and a cash-drawer movement itself. Your refund path is never called for a cash tender, so there's no paymentMethod variant for cash and nothing for your processor to implement.
Cash is also the only refund type for which InStore writes back to commercetools. At finalization, for each cash tender that references an original Payment, InStore adds a Refund Transaction to that Payment, in state Success and with a negative centAmount. InStore posts it once per tender: finalizing a session that is already Refunded adds nothing further. A cash tender that references no original Payment is still recorded and reported to you, but produces no commercetools Transaction.
A cash tender then appears in the finalize refund webhook alongside any credit, stored value, wallet, or pay-on-account tenders on the same return, with a Cash tenderInformation block. It also appears in the receipt data request as a tenderItems[] entry. Those two payloads are where your integration picks up a cash refund to keep its own systems in sync.

Refund webhook

For every processor-mediated tender, InStore posts to the refund path on the URL configured for the payment processor. This request differs from the payment requests described in Set up payment extensions. InStore posts the body below directly instead of wrapping it in the shared request fields, and beyond Content-Type it sends only the X-Signature and X-Correlation-Id headers. No X-API-Key or X-Transaction-Id header is included on this request, so do not make those a condition of accepting it.
Verify X-Signature as an HMAC-SHA256 of the exact request body, using the apiKey you configured for the processor. InStore waits for the timeout you configured for the processor.
POST {processorUrl}/refund
Sample refund requestjson
{
  "callbackUrl": "https://api.example.com/proj/instore-tenants/acme/payment-messages?type=result&streamId=req-1&nonce=...",
  "statusUrl": "https://api.example.com/proj/instore-tenants/acme/payment-messages?type=status&streamId=req-1&nonce=...",
  "requestId": "req-1",
  "location": { "typeId": "location", "key": "01" },
  "locationKey": "01",
  "workstation": { "typeId": "workstation", "key": "001" },
  "workstationKey": "001",
  "referencePayment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" },
  "amount": { "currencyCode": "USD", "centAmount": 4999, "fractionDigits": 2 },
  "paymentMethod": {
    "type": "BankCard",
    "device": { "id": "ped-1", "model": "P400", "registrationCode": "R-77" },
    "deviceId": "ped-1"
  },
  "additionalData": {
    "originalPaymentMethodName": "MasterCard 4444",
    "simulateMode": "async",
    "simulatePrompt": true
  }
}
FieldData typePresenceDescription
callbackUrlStringRequiredWhere to post the final result. Its exact form depends on whether long polling is enabled for the location, so treat it as an opaque URL and post to it verbatim.
statusUrlStringRequiredWhere to post intermediate messages, prompts, and input requests. Also opaque.
requestIdStringRequiredIdentifies this refund attempt. Use it to correlate your own records, and return it on the resolve path.
locationObjectRequiredA reference to the location. Carries key or id, whichever InStore held.
locationKeyStringRequiredThe same value as a plain string: location.key, or location.id when the reference carried no key. A reference must carry one of the two, so this is always sent.
workstationObjectRequiredA reference to the workstation.
workstationKeyStringRequiredAs locationKey, for the workstation.
referencePaymentObjectRequiredA reference to the original commercetools Payment being refunded.
amountObjectRequiredThe refund amount, as commercetools money. Positive.
paymentMethodObjectRequiredHow the refund is processed. See paymentMethod variants.
additionalDataObjectOptionalThe refund instruction's pspData, passed through unchanged. Sent only when the instruction carried one.
Respond either synchronously with the refund result, or with a 202 status code and then drive the refund through the events below. For the two response shapes, see Result events.

paymentMethod variants

InStore chooses the variant from the payment processor's integrationConfiguration.type.
Processor typepaymentMethodNotes
BankCard{ "type": "BankCard", "device": { "id", "model", "registrationCode" }, "deviceId" }The default variant: InStore sends it for BankCard and for any other PED-driven processor type. device.id is required. device.model and device.registrationCode are optional, and sent only when the workstation's active PED defines them. deviceId repeats device.id and is always sent for this variant.
GiftCard{ "type": "GiftCard" }Carries no further fields.
Wallet{ "type": "Wallet" }Carries no further fields.
PayOnAccount{ "type": "PayOnAccount", "customerId": "cust-8842" }customerId is optional, and sent only when the refund instruction or its fallback carried one. This is the only variant that carries the field. See Pay-on-account refund processing.

Status events

Once you've accepted a refund with 202, InStore waits for you to drive it to completion. Post progress and prompts to the request's statusUrl, then exactly one result to its callbackUrl.
POST {statusUrl}
Every event, on either URL, uses the same envelope: an event name and a data object. Both are required. InStore skips any event where event isn't a string or data isn't an object, so send data even when it's empty.
All refund status events use the event name PaymentRefundUpdate, and data.kind selects what InStore does with the event.
Sample progress messagejson
{
  "event": "PaymentRefundUpdate",
  "data": { "kind": "message", "message": "Contacting payment network", "severity": "info" }
}
FieldData typePresenceDescription
eventStringRequiredAlways PaymentRefundUpdate for a status event.
data.kindStringRequiredmessage, prompt, or input. InStore logs and ignores any other value.
data.keepAliveNumberOptionalMilliseconds. Replaces the remaining timeout for this refund attempt with a fresh window of this length, so a long step doesn't trip the timeout configured on the processor. Valid on any kind.
data.messageStringOptionalThe text InStore shows the associate while the refund runs. InStore displays an empty message when you omit it, so send one on every message event.
data.severityStringOptionalForwarded to the InStore POS with the message.
data.propertiesObjectOptionalFree-form data forwarded to the InStore POS with a message event.
data.requestIdStringOptionalFor prompt, InStore falls back to the requestId of the attempt in flight when you omit it, which is correct as long as you raise one prompt at a time. For input there's no fallback: see Input requests.
data.title, data.message, data.options, data.primaryVariousOptionalFor prompt. See Prompts.
data.inputType, data.inputData, data.callbackUrlVariousOptionalFor input. See Input requests.
keepAlive sits inside data and is in milliseconds. It isn't the same field as the keepAliveMs used by the payment status callback.

Prompts

To ask the associate something during a refund, such as confirming the refund on the terminal, send a status event with kind: "prompt". InStore shows a dialog and posts the answer to the resolve path on your processor URL.
Sample promptjson
{
  "event": "PaymentRefundUpdate",
  "data": {
    "kind": "prompt",
    "keepAlive": 20000,
    "title": "Confirm Refund",
    "message": "Operator must confirm the refund on the terminal",
    "options": ["Accept", "Decline"],
    "primary": "Accept"
  }
}
FieldData typePresenceDescription
data.titleStringOptionalThe dialog title. InStore shows an empty title when you omit it.
data.messageStringOptionalThe question put to the associate.
data.optionsArrayOptionalThe answers offered. Each string is both the button label and the value InStore sends back. When you omit it or send an empty array, InStore offers a single option: primary, or Accept when you sent no primary either.
data.primaryStringOptionalWhich of options means "proceed with the refund."
data.keepAliveNumberOptionalMilliseconds to wait for the answer. Send one sized to how long an associate realistically takes, or the refund can time out while the dialog is still open.
primary decides the outcome, not just the styling. InStore treats the answer matching primary as "continue," and every other answer as a decline: it fails the refund attempt locally and moves to the next fallback. If primary is missing, or isn't one of options, then no answer counts as "continue" and every answer declines the refund.
InStore posts the answer to your resolve path, signed the same way as the refund request:
POST {processorUrl}/resolve
Sample resolve requestjson
{
  "location": { "typeId": "location", "key": "01" },
  "locationKey": "01",
  "workstation": { "typeId": "workstation", "key": "001" },
  "workstationKey": "001",
  "referencePayment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" },
  "paymentProcessorId": "691bb93ff14fd895922ceb16",
  "requestId": "req-1",
  "response": "Accept"
}
FieldData typePresenceDescription
location / workstation / referencePaymentObjectRequiredThe same references as on the refund request.
locationKey / workstationKeyStringRequiredThe plain-string forms, as on the refund request.
paymentProcessorIdStringRequiredThe processor the prompt belongs to.
requestIdStringRequiredThe refund attempt the prompt belongs to. Match it against the attempt you're holding open.
responseStringRequiredThe answer the associate chose, exactly as you listed it in options. When you omitted options, it's the single option InStore synthesized instead: your primary, or Accept when you sent no primary either. Branch on this value with that fallback in mind, since it can be a string you never listed.
additionalDataObjectOptionalPassed through when the InStore POS supplies one.
Respond with a 2xx status code and any body: InStore relays both to the InStore POS unchanged. A non-2xx response doesn't reach the InStore POS as-is; it surfaces there as an error.
Answering a prompt doesn't end the refund. When the answer is the primary one, the refund is still running and InStore waits for your result event on callbackUrl. Without it, the attempt runs to timeout. When the answer is anything else, InStore has already failed the attempt and moved to the next fallback, and a result arriving afterwards changes nothing on the InStore side.

Input requests

kind: "input" asks the host application for a value mid-refund, instead of putting a dialog in front of the associate.
Sample input requestjson
{
  "event": "PaymentRefundUpdate",
  "data": {
    "kind": "input",
    "requestId": "req-1",
    "inputType": "signature",
    "inputData": {},
    "callbackUrl": "https://processor.example.com/refund/req-1/input"
  }
}
FieldData typePresenceDescription
data.inputTypeStringOptionalWhat you're asking for. The host application decides how to collect it.
data.inputDataObjectOptionalContext so that the host application can collect the value.
data.callbackUrlStringOptionalWhere the host application sends the collected value. This is your own endpoint, carried in the event.
data.requestIdStringOptionalThe refund attempt this belongs to. Unlike a prompt, InStore applies no fallback here: it forwards whatever you send, so omit it only if the host application can work without it.
An input request behaves differently from a prompt. InStore renders nothing and answers nothing: it raises the request in the host application and passes these four values straight through. The host application collects the value and posts it to the callbackUrl in the event, not to your resolve path, so InStore is out of the loop for the answer.
Only send kind: "input" if the host application deployed in your stores handles InStore input requests. Nothing in InStore answers one, so if the host application ignores it the refund sits until the attempt times out.

Result events

POST {callbackUrl}

Post exactly one result per refund attempt. InStore stops accepting events for the attempt as soon as one arrives.

Sample successful refundjson
{
  "event": "Refunded",
  "data": {
    "payment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" },
    "transactionId": "0f6d6b4f-9b3f-4f26-9f0a-2a0f6b2b8a11",
    "label": "Refund",
    "amount": { "currencyCode": "USD", "centAmount": 4999, "fractionDigits": 2 }
  }
}
Sample declined refundjson
{
  "event": "RefundFailure",
  "data": {
    "status": "Declined",
    "transactionId": "req-1",
    "reason": "Operator declined the refund"
  }
}
FieldData typePresenceDescription
eventStringRequiredRefunded marks the refund approved. Any other name, for example RefundFailure, marks it declined.
data.statusStringOptionalOverrides the outcome taken from event. InStore carries on only when the effective status is exactly Success, and treats anything else as a decline, moving to the next fallback.
data.reasonStringOptionalWhy the refund was declined. InStore shows it to the associate; without it, the associate sees a generic decline message.
data.labelStringOptionalThe method label. InStore records it as the tender's payment method, which is what appears on the refund receipt.
data.transactionIdStringOptionalYour identifier for the refund. InStore doesn't store it, so it doesn't reappear in the finalize refund webhook. Keep your own mapping from requestId.
data.amountObjectOptionalInformational. The actual amount refunded. commercetools does not store this value automatically: record it yourself if you need it. InStore records only the amount it asked you to refund.
data.paymentObjectOptionalInformational. InStore records the Payment from the refund instruction.
data.status wins over event. A Refunded event carrying "status": "Declined" is treated as a decline, and a RefundFailure event carrying "status": "Success" is treated as an approved refund. Send status only when you mean to set the outcome with it.

Finalize refund webhook

When the refund session is finalized, InStore sends a webhook notification to the webhook URL configured for your tenant, so you can update your own systems.

InStore signs this request the same way it signs other webhook requests. For more information about these, see Header information that you receive in all InStore payment requests. In addition to X-Signature and X-Correlation-Id, this request also includes an x-tenant-id header that identifies the tenant, and an X-API-Key header when your tenant has a webhook API key configured.
Sample finalize refund webhook payloadjson
{
  "tenant": { "type": "tenant", "key": "acme-retail" },
  "location": { "type": "location", "key": "01" },
  "workstation": { "type": "workstation", "key": "001" },
  "action": "Refund",
  "data": {
    "refundSessionId": "6a3c08d503353f78bbe2c08f",
    "refundTransactions": [
      {
        "paymentOption": { "typeId": "paymentOption", "id": "option-credit-us-1" },
        "amount": { "currencyCode": "USD", "centAmount": 4999, "fractionDigits": 2 },
        "originalRefundTransactionId": "txn_original_payment_1",
        "tenderInformation": {
          "type": "Credit",
          "tenderItemId": "6a3c091003353f78bbe2c090",
          "payment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" }
        }
      },
      {
        "paymentOption": { "typeId": "paymentOption", "id": "option-poa-us-1" },
        "amount": { "currencyCode": "USD", "centAmount": 2000, "fractionDigits": 2 },
        "originalRefundTransactionId": "txn_original_payment_1",
        "tenderInformation": {
          "type": "POA",
          "tenderItemId": "6a3c091003353f78bbe2c092",
          "payment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" },
          "customerId": "cust-8842"
        }
      },
      {
        "paymentOption": { "typeId": "paymentOption", "id": "option-cash-us-1" },
        "amount": { "currencyCode": "USD", "centAmount": 500, "fractionDigits": 2 },
        "originalRefundTransactionId": "txn_original_payment_1",
        "tenderInformation": {
          "type": "Cash",
          "tenderItemId": "6a3c091003353f78bbe2c091",
          "roundedRemainder": { "currencyCode": "USD", "centAmount": -1, "fractionDigits": 2 }
        }
      }
    ]
  }
}
FieldData typePresenceDescription
tenant / location / workstationObjectRequiredScope references, as { type, key }.
actionStringRequiredAlways Refund for this notification. Branch your webhook handler on this value alongside the payment, refund, and receipt actions you already handle. The lowercase refund action belongs to the older refund flow and carries an entirely different body.
data.refundSessionIdStringRequiredThe internal identifier of the InStore refund session that was finalized. Use it to correlate with the receipt data request.
data.refundTransactionsArrayRequiredOne entry per tender that made up the refund, in the order they were finalized. A session can have multiple entries when a return is split between the original payment method and a fallback tender.
data.refundTransactions[].paymentOptionObjectRequiredA reference to the payment option the tender was recorded against. Contains typeId and id. The id carries whichever identifier InStore recorded on the refund session, which is the option's key when the refund instruction referenced it by key. id is optional within the object, but a tender recorded through the refund flow always carries a payment option, so it's always present in practice.
data.refundTransactions[].amountObjectRequiredThe tender amount, as commercetools money. fractionDigits is optional and absent when the tender was recorded without a precision.
data.refundTransactions[].originalRefundTransactionIdStringRequiredThe identifier of the original payment transaction that this tender refunds. Use this to match the refund back to the payment you are reversing.
data.refundTransactions[].tenderInformationObjectRequiredTender-specific data for the tender being refunded. Shape depends on the tender type.
data.refundTransactions[].tenderInformation.typeStringRequiredThe tender type: Cash, Credit, Wallet, GiftCard, or POA.
data.refundTransactions[].tenderInformation.tenderItemIdStringRequiredThe internal identifier of the InStore tender. Present for every tender type. Matches data.tenderItems[]._id in the receipt data request.
data.refundTransactions[].tenderInformation.paymentObjectRequired for processor-mediated tendersA reference to the commercetools Payment the tender refunds. Contains typeId and id, where id is the tender's external_payment_id when one was recorded, otherwise its payment_id. id is optional within the object, and absent when the tender carried neither.
data.refundTransactions[].tenderInformation.roundedRemainderObjectOptional, Cash onlyThe cash-rounding adjustment applied to the refund, as commercetools money. May be negative. Sent whenever InStore recorded a rounding value for the tender, with a centAmount of 0 when the refund needed no rounding, and absent when the tender recorded none at all.
data.refundTransactions[].tenderInformation.customerIdStringOptional, POA onlyThe customer account the refund was credited to, taken from the refund session. Absent when no refund instruction or fallback carried one, since customerId is optional. No other tender type carries this field.
The tenderInformation shapes are:
typeFieldsNotes
CashtenderItemId, roundedRemainderCash carries no payment object. When the tender referenced an original Payment, InStore added the refund Transaction to it at finalization.
CredittenderItemId, paymentThe default for any processor-mediated tender that isn't a wallet, stored value, or pay-on-account refund.
WallettenderItemId, paymentIdentical to Credit apart from the discriminator.
GiftCardtenderItemId, paymentIdentical to Credit apart from the discriminator.
POAtenderItemId, payment, customerIdThe only shape that adds customerId, and only when the refund supplied one.
For delegated Credit, GiftCard, Wallet, and POA tenders, InStore does not reverse a commercetools Payment or Transaction. Your payment processor integration remains responsible for the reversal. For a cash tender, InStore adds the refund transaction to the commercetools Payment before sending this webhook. For more information, see Credit, Stored value, Wallet, and Pay-on-account refund processing.
InStore sends this webhook on a best-effort basis and does not wait for your response before completing the return in the InStore POS. Respond with a 200 status code to acknowledge receipt.

If your webhook service doesn't acknowledge the request, InStore retries up to three times using exponential backoff, with a five-second timeout on each attempt. InStore waits up to one second before the first retry, up to two seconds before the second retry, and up to four seconds before the third retry. Then, InStore stops retrying. This does not affect the finalized refund session. Your integration is responsible for reconciling any missed notifications through your own means.

Each refund instruction in the refund object must specify its own refundAmount, paymentOption, and originalCTPayment. InStore does not calculate split refund amounts or select target payment options for you.

Refund receipt data webhook

To print a refund receipt, InStore requests data for a finalized refund session from your webhook URL. Unlike the finalize refund webhook, this is a synchronous request. InStore waits for your response and uses it to render the refund receipt.
InStore signs this request the same way it signs other webhook requests. In addition to X-Signature and X-Correlation-Id, this request also includes the following headers:
NamePurpose
X-API-KeyA key to authenticate the InStore API call to your webhook service.
x-tenant-idIdentifies the tenant that is being called.

Because the response contains cart contents, InStore refuses to send this request at all if your tenant has no webhook verification key configured.

Both blocks in data are stored records rather than assembled payloads, so some of their nested objects carry a storage _id of their own: refunds[].refundAmount, tenderItems[].amount, and a tender's cash block. Your integration doesn't need to read these.
Sample refund receipt data request payloadjson
{
  "tenant": { "typeId": "tenant", "key": "acme-retail" },
  "location": { "typeId": "location", "key": "01" },
  "workstation": { "typeId": "workstation", "key": "001" },
  "action": "RefundReceiptDataRetrieval",
  "data": {
    "refundSession": {
      "refundSessionId": "6a3c08d503353f78bbe2c08f",
      "returnCart": { "typeId": "cart", "key": "cart-1" },
      "returnOrders": [
        {
          "order": { "typeId": "order", "key": "order-1" },
          "returnLineItem": [{ "typeId": "line-item", "id": "193eb21f-a5e7-4327-a6c9-705282da9f30" }]
        }
      ],
      "refunds": [
        {
          "refundAmount": { "amount": 4999, "currency": "USD", "_id": "6a3c08d503353f78bbe2c08e" },
          "paymentOption": { "typeId": "paymentOption", "key": "option-credit-us-1" },
          "order": { "typeId": "order", "key": "order-1" },
          "originalCTPayment": { "typeId": "payment", "id": "pay_3Nk82jLkQ4eR1mWx" },
          "metaData": { "prompt": "Show1", "promptInfo": "Show2" },
          "fallbackRefundTypes": []
        }
      ],
      "status": "Refunded"
    },
    "tenderItems": [
      {
        "_id": "6a3c091003353f78bbe2c090",
        "session_id": "6a3c08d503353f78bbe2c08f",
        "transaction_type": "refund",
        "tender_type": "Credit",
        "amount": { "amount": 4999, "currency": "USD", "precision": 2, "_id": "6a3c091003353f78bbe2c093" },
        "reference": "cart-1",
        "payment_id": "pay_3Nk82jLkQ4eR1mWx",
        "original_reference": "order-1",
        "payment_option_id": "option-credit-us-1",
        "report": { "tenderGroup": "Credit" },
        "tenant_id": "acme-retail",
        "created_at": "2026-07-22T10:25:43.194Z",
        "__v": 0
      }
    ]
  }
}
FieldData typePresenceDescription
tenant / location / workstationObjectRequiredScope references. These use typeId, not the type used by the finalize refund webhook.
actionStringRequiredAlways RefundReceiptDataRetrieval for this request.
data.refundSessionObjectRequiredThe refund session and its current status. It includes the return Cart, return Orders, and refunds that InStore built when the return was initiated.
data.refundSession.refundSessionIdStringRequiredThe internal identifier of the InStore refund session.
data.refundSession.returnCartObjectRequiredA reference to the Cart created for the returned items. Contains typeId, and whichever of key and id the refund object carried. InStore persists the reference as you sent it, and doesn't fill in the identifier you omitted.
data.refundSession.returnOrdersArrayRequiredThe Orders and Line Items being returned, each with order (a reference) and returnLineItem (an array of references).
data.refundSession.refundsArrayRequiredThe refund instructions for the session, as persisted. Includes refundAmount, paymentOption, order, originalCTPayment, and fallbackRefundTypes, plus transactionId, pspData, and metaData when the refund object carried them. A refund instruction's own customerId isn't persisted at this level: read it from fallbackRefundTypes[].customerId, or take the account from tenderInformation.customerId on the finalize refund webhook.
data.refundSession.statusStringRequiredThe refund session's current status, for example Refunded.
data.tenderItemsArrayRequiredEvery tender recorded against the session, in the raw shape InStore persists it. Amounts use InStore tender money.
data.tenderItems[]._idStringRequiredThe tender identifier. Matches tenderInformation.tenderItemId in the finalize refund webhook.
data.tenderItems[].session_idStringRequiredThe refund session the tender belongs to.
data.tenderItems[].transaction_typeStringRequiredAlways refund for these tenders.
data.tenderItems[].tender_typeStringRequiredThe tender type: Cash, Credit, GiftCard, Wallet, or POA.
data.tenderItems[].amountObjectRequiredThe tender amount: { amount, currency, precision }.
data.tenderItems[].cashObjectOptional, Cash tenders onlyThe cash-rounding adjustment applied to the tender: { roundedRemainder: { amount, currency, precision } }.
data.tenderItems[].referenceStringRequiredThe return Cart this tender was taken against.
data.tenderItems[].original_referenceStringOptionalThe original Order the refund relates to.
data.tenderItems[].payment_idStringOptionalThe original commercetools Payment being refunded.
data.tenderItems[].payment_option_idStringOptionalThe payment option this tender was taken against.
data.tenderItems[].report.tenderGroupStringOptionalThe reporting group frozen from the payment option when the tender was created. The X and Z reports group on this rather than on tender_type. Absent when the option resolved to no group; the report then counts the amount as Other.
data.tenderItems[].tenant_idStringRequiredThe tenant the tender belongs to.
data.tenderItems[].created_atStringRequiredWhen the tender was recorded, in ISO 8601 format.
data.tenderItems[].__vNumberRequiredAn internal storage version key that your integration doesn't need to read.
Respond within five seconds with a 2xx status code and the receipt data InStore uses to print the refund receipt. For the response fields and a sample, see Refund receipt template.
If your webhook service returns a non-2xx status, times out, or is unreachable, InStore doesn't retry. The refund session is already finalized, so the return isn't affected. Instead, the InStore POS shows an empty receipt preview, and the store associate can still complete the refund without printing or emailing a receipt.