NAME

WWW::PayPal::API::Webhooks - PayPal Webhooks API (v1) — management + signature verification

VERSION

version 0.003

SYNOPSIS

# One-time, per-environment setup: register the receiver
my $webhook = $pp->webhooks->create(
    url         => 'https://example.com/paypal/webhook',
    event_types => [
        'CHECKOUT.ORDER.APPROVED',
        'PAYMENT.CAPTURE.COMPLETED',
        'BILLING.SUBSCRIPTION.ACTIVATED',
        'PAYMENT.SALE.COMPLETED',
    ],
    # or import symbols from WWW::PayPal::WebhookEvents
);
# store $webhook->id in per-environment config (sandbox != live)

# In the receiver, for every incoming event:
my $ok = $pp->webhooks->verify(
    webhook_id        => $config->{webhook_id},   # NEVER hard-code / default
    raw_body          => $raw_bytes,              # the untouched request body
    transmission_id   => $req->header('Paypal-Transmission-Id'),
    transmission_time => $req->header('Paypal-Transmission-Time'),
    transmission_sig  => $req->header('Paypal-Transmission-Sig'),
    cert_url          => $req->header('Paypal-Cert-Url'),
    auth_algo         => $req->header('Paypal-Auth-Algo'),
);
return unless $ok;   # forged / tampered — do not act on it

DESCRIPTION

Controller for PayPal's Webhooks API: register/list/delete webhook endpoint subscriptions, and — the security-critical part — verify the signature of an incoming webhook event via PayPal's POST /v1/notifications/verify-webhook-signature endpoint.

Verification is done through PayPal's endpoint rather than local certificate chain validation: it keeps the operation table hand-maintained and adds no crypto dependency, at the cost of one extra API round-trip per event.

Receiver discipline

An unverified receiver is a "grant everyone premium" endpoint. Whatever framework you receive with:

  • Always "verify" before acting on an event. The payload alone is not authentication.

  • Capture the raw request body before anything parses it. Verification signs the exact bytes PayPal sent; a framework that decodes and re-serialises JSON changes the bytes and the signature will never verify. See "verify" for how this module keeps those bytes intact.

  • Dedupe on the event id in storage with a unique index before doing any work — PayPal redelivers events, for up to ~3 days, until you answer 2xx.

  • Answer 2xx fast and process asynchronously. Slow handlers cause retries, retries cause duplicates.

  • Do not assume event ordering. BILLING.SUBSCRIPTION.ACTIVATED may arrive after the first PAYMENT.SALE.COMPLETED; handlers must be commutative or re-fetch the object.

verify

my $ok = $pp->webhooks->verify(
    webhook_id        => $config_webhook_id,
    raw_body          => $raw_request_bytes,
    transmission_id   => $headers{'paypal-transmission-id'},
    transmission_time => $headers{'paypal-transmission-time'},
    transmission_sig  => $headers{'paypal-transmission-sig'},
    cert_url          => $headers{'paypal-cert-url'},
    auth_algo         => $headers{'paypal-auth-algo'},
);

Verifies an incoming webhook event's signature against PayPal. All seven arguments are required and named (never positional) and each croaks if missing or empty. In particular webhook_id is never defaulted — it is per-environment, and a sandbox ID will not verify a live event or vice versa.

Returns a real boolean: 1 when PayPal reports verification_status = SUCCESS>, 0 otherwise. A forged or tampered event comes back as HTTP 200 with status FAILURE, so this returns 0 without croaking — a false result is the expected "reject it" path, not an error. Only a transport error or a 4xx/5xx croaks (from WWW::PayPal::Role::HTTP).

raw_body must be the untouched bytes of the request as PayPal sent them; this method splices them into the verification payload verbatim rather than decoding and re-encoding, so the signed bytes are preserved. Passing a decoded structure (an ArrayRef/HashRef) is a reference and croaks; passing bytes that you already round-tripped through a JSON parser will verify as FAILURE.

create

my $webhook = $pp->webhooks->create(
    url         => 'https://example.com/paypal/webhook',
    event_types => [ 'PAYMENT.CAPTURE.COMPLETED', 'CHECKOUT.ORDER.APPROVED' ],
);

Registers a webhook endpoint. Each event_types element may be a plain event-name string (or a WWW::PayPal::WebhookEvents constant), which this method wraps into PayPal's required { name => ... } shape for you, or an already-wrapped { name => ... } HashRef as PayPal's own documentation shows it — those pass through untouched, so the two forms may be mixed freely in one list. Returns a WWW::PayPal::Webhook.

list

my $webhooks = $pp->webhooks->list;

Returns an ArrayRef of WWW::PayPal::Webhook for every registered endpoint.

get

my $webhook = $pp->webhooks->get($id);

Fetches a single webhook by ID. Returns a WWW::PayPal::Webhook.

delete

$pp->webhooks->delete($id);

Deletes a webhook endpoint. PayPal answers 204 No Content, so this returns a plain true value rather than an entity.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-www-paypal/issues.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.