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.
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, "")
endAlways 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
@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)
@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.
@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.