# Adyen refund issued or failed notifications on your phone

> Get a low-priority push notification when an Adyen refund goes through, and a High-priority one when a refund fails or bounces back.

- Company: Adyen (https://www.justpush.io/recipes/adyen)
- Event: `REFUND` (REFUND, REFUND_FAILED and REFUNDED_REVERSED)
- Tags: Payments & billing, Refunds
- Install: https://studio.justpush.io/recipes/adyen/refund
- Web page: https://www.justpush.io/recipes/adyen/refund

## Setup

1. Click Install in Studio and sign in. The recipe is added to your account and you get a personal webhook URL.
2. In your Adyen Customer Area, go to Developers > Webhooks, select Create new webhook and add a Standard webhook.
3. Paste your webhook URL (shown in Studio after install) as the URL and set Method to JSON.
4. Under Events settings, check that REFUND, REFUND_FAILED and REFUNDED_REVERSED is selected, then select Save configuration.
5. Use a **Standard** webhook and set **Method** to **JSON**. The HTTP POST and SOAP methods send the data in a different format that this recipe can't read. `REFUND`, `REFUND_FAILED` and `REFUNDED_REVERSED` are all default events of the Standard webhook.
6. Successful refunds get a Low-priority push. To only hear about refunds that fail or bounce back, use `your webhook URL (shown in Studio after install)?only=failed` as the webhook URL instead.
7. If you use `/cancelOrRefund`, Adyen reports it as a `CANCEL_OR_REFUND` event, which this recipe doesn't cover.
8. Adyen's **Test configuration** button can send `REFUNDED_REVERSED` but not `REFUND`, so the most realistic check is to refund a payment in your test Customer Area; that push is marked **(test mode)**. Adyen only needs a 2xx response; the old `[accepted]` response body isn't required.
9. Adyen signs each event with an HMAC signature in `additionalData.hmacSignature`, but JustPush doesn't check it yet, so keep your endpoint URL private. You can leave the HMAC key and basic authentication settings empty.

## Code

Studio calls `handleRequest(request)` with the incoming webhook (`{ method, headers, body, query, raw }`) and sends the message object it returns. Returning `null` sends nothing.

```js
// Adyen → REFUND (plus REFUND_FAILED and REFUNDED_REVERSED)
// Fires when a refund is processed or rejected, when the card scheme later rejects a refund,
// or when a refund bounces back to your Adyen account.
// Docs: https://docs.adyen.com/development-resources/webhooks/webhook-types/
//
// Needs a Standard webhook (Customer Area > Developers > Webhooks) with Method set to JSON,
// pointing at the integration's endpoint URL. Add ?only=failed to skip successful refunds.

// Adyen amounts are in minor units, and Adyen sets the decimals per currency. Everything not
// listed here has two. https://docs.adyen.com/development-resources/currency-codes
const DECIMALS = {
    CVE: 0, DJF: 0, GNF: 0, IDR: 0, JPY: 0, KMF: 0, KRW: 0, PYG: 0,
    RWF: 0, UGX: 0, VND: 0, VUV: 0, XAF: 0, XOF: 0, XPF: 0,
    BHD: 3, IQD: 3, JOD: 3, KWD: 3, LYD: 3, OMR: 3, TND: 3,
}

// The event codes this recipe handles.
const EVENTS = {
    REFUND: true,
    REFUND_FAILED: true,
    REFUNDED_REVERSED: true,
}

// Own-property lookup, so keys like "toString" fall back instead of hitting Object.prototype.
function pick(table, key, fallback) {
    return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback
}

// A trimmed string, or null. Adyen sometimes sends the literal string "null".
function text(value) {
    if (typeof value !== "string") return null
    const trimmed = value.trim()
    return trimmed && trimmed.toLowerCase() !== "null" ? trimmed : null
}

// { value: 1500, currency: "EUR" } → "€15.00". Returns null when either part is missing.
function money(amount) {
    const raw = amount?.value
    const minor = typeof raw === "string" && /^-?\d+$/.test(raw) ? Number(raw) : raw
    const code = text(amount?.currency)?.toUpperCase()

    if (typeof minor !== "number" || !Number.isFinite(minor) || !code) return null

    const value = minor / 10 ** pick(DECIMALS, code, 2)

    try {
        return new Intl.NumberFormat("en-GB", { style: "currency", currency: code }).format(value)
    } catch {
        return `${value} ${code}`
    }
}

// The NotificationRequestItem objects in the body. JSON webhooks carry one, but it's an array.
function notificationItems(body) {
    const list = Array.isArray(body?.notificationItems) ? body.notificationItems : []
    return list.map((wrapper) => wrapper?.NotificationRequestItem).filter((item) => item && typeof item === "object")
}

// Builds the push for one refund item, or null when it isn't worth one.
function describe(item, amount) {
    const reason = text(item.reason)
    const success = String(item.success).toLowerCase()

    if (item.eventCode === "REFUND" && success === "true") {
        return {
            title: "↩️ Refund issued",
            message: amount ? `${amount} refunded` : "A refund was issued",
            priority: -1, // Low — you (or a teammate) most likely issued it yourself
            failed: false,
        }
    }

    if (item.eventCode === "REFUND" && success === "false") {
        return {
            title: "⚠️ Refund failed",
            message: `${amount ? `${amount} refund` : "A refund"} couldn't be processed` + (reason ? `: ${reason}` : ""),
            priority: 1, // High — the shopper is still waiting for their money
            failed: true,
        }
    }

    if (item.eventCode === "REFUND_FAILED") {
        return {
            title: "⚠️ Refund failed",
            message:
                `${amount ? `${amount} refund` : "A refund"} was rejected by the card scheme` +
                (reason && reason.toLowerCase() !== "refund failed" ? `: ${reason}` : ""),
            priority: 1, // High — the shopper is still waiting for their money
            failed: true,
        }
    }

    if (item.eventCode === "REFUNDED_REVERSED") {
        return {
            title: "↪️ Refund reversed",
            message: `${amount ? `${amount} refund` : "A refund"} came back to your Adyen account, so the shopper didn't get it`,
            priority: 1, // High — the shopper still needs to be paid another way
            failed: true,
        }
    }

    return null
}

function handleRequest(request) {
    const body = request.body && typeof request.body === "object" ? request.body : {}
    const items = notificationItems(body)

    // A custom test notification from Adyen; confirm the connection quietly.
    const ping = items.find((item) => item.eventCode === "NOTIFICATIONTEST")
    if (ping) {
        return {
            title: "🔔 Adyen connected",
            message: `Webhook for ${text(ping.merchantAccountCode) ?? "your merchant account"} is working`,
            topic: "Adyen",
            priority: -1,
        }
    }

    // Guard: only refund items, so other Adyen events on this integration stay quiet.
    const matches = items.filter((item) => pick(EVENTS, item.eventCode, false))
    if (!matches.length) {
        console.log(`Ignoring Adyen "${items[0]?.eventCode ?? "unknown"}" webhook`)
        return null
    }

    const item = matches[0]
    const push = describe(item, money(item.amount))
    if (!push) {
        console.log(`Skipping ${item.eventCode} with success "${item.success}"`)
        return null
    }

    // ?only=failed on the webhook URL mutes successful refunds.
    if (String(request.query?.only ?? "").toLowerCase() === "failed" && !push.failed) {
        console.log("Skipping successful refund (?only=failed)")
        return null
    }

    const ref = text(item.merchantReference)
    const more = matches.length > 1 ? ` (+${matches.length - 1} more)` : ""
    const test = body.live === "false" || body.live === false ? " (test mode)" : ""

    return {
        title: push.title,
        message: push.message + (ref ? ` — ${ref}` : "") + more + test,
        topic: "Adyen",
        priority: push.priority,
    }
}
```

## Adyen REFUND webhook payload (sample)

```json
{
  "live": "true",
  "notificationItems": [
    {
      "NotificationRequestItem": {
        "amount": {
          "value": 1500,
          "currency": "EUR"
        },
        "reason": "",
        "success": "true",
        "eventCode": "REFUND",
        "eventDate": "2026-09-29T14:02:11+02:00",
        "pspReference": "V4HZ4RBFJGXXGN82",
        "paymentMethod": "visa",
        "additionalData": {
          "hmacSignature": "REDACTED_BASE64_SIGNATURE=",
          "paymentMethodVariant": "visa"
        },
        "merchantReference": "ORDER-12345",
        "originalReference": "QFQTPCQ8HXSKGK82",
        "merchantAccountCode": "YourCompanyECOM"
      }
    }
  ]
}
```

## FAQ

### Does this work on iPhone and Android?

Yes. Install the JustPush app from the App Store or Google Play and sign in. Every phone signed in to your account gets the notification.

### Do I need to write code?

No. Install the recipe in Studio, paste your webhook URL into Adyen and you are done. The code is there if you want to change the text, the sound or the buttons.

### Can I change what the notification says?

Yes. After install the recipe's code is yours. Edit it in Studio and test it against the sample payload before you save.

### What does it cost?

JustPush is free for 30 days. After that it's $19.99 a year, or $39.99 once. There is no extra charge for recipes.
