Sumup.Webhooks (Sumup v1.0.0)

Copy Markdown View Source

Handling for SumUp's online-payments webhooks.

There is no signature to verify

Unlike Stripe, GitHub, or most other webhook senders, SumUp's Checkout webhooks are not signed. There is no HMAC, no signing secret, and no X-Signature-style header to check for this API. SumUp's own documentation is explicit about what to do instead:

After receiving a webhook call, your application must always verify if the event really took place, by calling a relevant SumUp's API.

https://developer.sumup.com/online-payments/webhooks

This module reflects that reality rather than inventing a signature scheme that doesn't exist for this API — doing so would give you false assurance about a check that isn't actually happening. parse/1 decodes the (unauthenticated, spoofable) payload; verify/2 performs the re-fetch SumUp recommends, so what your application actually acts on is the server-confirmed checkout state, not whatever a POST body claimed.

If this surprises you: SumUp does have HMAC-signed webhooks, but only for its unrelated Open Banking / Payment Initiation product (a different API, documented separately, using an RSA/EC public-key signature — not a shared-secret HMAC). Nothing in this module applies to that product.

Setup

There's no separate webhook-registration endpoint for Checkouts. Subscribe by passing return_url: when creating a checkout (see Sumup.Checkouts.create/2).

Handling incoming requests

def handle_webhook(conn, _params) do
  config = my_sumup_config()

  with {:ok, raw_body, conn} <- Plug.Conn.read_body(conn),
       {:ok, event} <- Sumup.Webhooks.parse(raw_body),
       {:ok, checkout} <- Sumup.Webhooks.verify(event, config) do
    MyApp.handle_checkout_update(checkout)
  else
    # Still ack with 2xx even on a parse/lookup failure — see below.
    _ -> :ok
  end

  Plug.Conn.send_resp(conn, 200, "")
end

Always respond with a 2xx status quickly, independent of how — or whether — you finish processing the event. SumUp treats any non-2xx response as a failed delivery and retries at 1 minute, 5 minutes, 20 minutes, and 2 hours; if your handler is slow, ack first and process asynchronously.

Summary

Functions

Convenience: parses and verifies in one call.

Decodes a webhook payload — either a raw JSON request body or an already-decoded map — into a Sumup.WebhookEvent.

The trustworthy way to act on a webhook event: re-fetches the checkout referenced by event.id directly from the Checkouts API, and returns its server-confirmed state.

Functions

handle(body, config)

@spec handle(String.t() | map(), Sumup.Config.t()) ::
  {:ok, Sumup.Checkout.t()}
  | {:error, Sumup.Error.t() | :unsupported_event_type}

Convenience: parses and verifies in one call.

{:ok, checkout} = Sumup.Webhooks.handle(raw_body, config)

parse(body)

@spec parse(String.t() | map()) ::
  {:ok, Sumup.WebhookEvent.t()} | {:error, Sumup.Error.t()}

Decodes a webhook payload — either a raw JSON request body or an already-decoded map — into a Sumup.WebhookEvent.

This performs no verification. The result reflects only what the HTTP request claimed; pass it to verify/2 before trusting it for anything that matters.

verify(webhook_event, config)

@spec verify(Sumup.WebhookEvent.t(), Sumup.Config.t()) ::
  {:ok, Sumup.Checkout.t()}
  | {:error, Sumup.Error.t() | :unsupported_event_type}

The trustworthy way to act on a webhook event: re-fetches the checkout referenced by event.id directly from the Checkouts API, and returns its server-confirmed state.

Only meaningful for :checkout_status_changed events. Any other event type — including ones this library predates, which SumUp may introduce without notice — returns {:error, :unsupported_event_type} rather than guessing, so you don't accidentally treat an unrelated event as a checkout id. Per SumUp's guidance, the right response to an unrecognized event type is to silently ignore it, not fail loudly.