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
idin 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.ACTIVATEDmay arrive after the firstPAYMENT.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.