paubox 1.3.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +7 -0
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +11 -0
- data/README.md +111 -0
- data/api.md +214 -0
- data/lib/paubox/client.rb +0 -32
- data/lib/paubox/version.rb +1 -1
- data/lib/paubox/webhooks_client.rb +197 -0
- data/lib/paubox.rb +2 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa4eb8413cee31b007b8dd6adb1dc4342891fab4f46869f68adabb7d3ac4a7a2
|
|
4
|
+
data.tar.gz: e310784bb8c7963d5e791662ecccebc50253f33c341864bd3260409e5594293a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: baff84836c819530292367f0ab8b2990afb4d5e46f9a8991fb72a2b93263f3566c4e37b8b5c842e965c6815445c23e895e86bf0b23357ac698796a74e27d2968
|
|
7
|
+
data.tar.gz: 9d2ba57f05e491258e4fba02d4d3c6cbc1690789b1a8eaf9ba4504b8c771d645a39f9ff61588c3fe939891ca9935b5fe825920e28119b74f1fffb07cac271308
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -58,4 +58,11 @@ jobs:
|
|
|
58
58
|
puts Paubox::Client.new.send(:api_base_endpoint)
|
|
59
59
|
f = Paubox::FormsClient
|
|
60
60
|
puts "#{f::FORMS_PROTOCOL}#{f::FORMS_HOST}#{f::FORMS_BASE}"
|
|
61
|
+
# The webhooks base is probed with /endpoints appended. The gateway
|
|
62
|
+
# scopes its rule to that one resource so the producers event-ingest
|
|
63
|
+
# route stays off the public host, which leaves the bare base
|
|
64
|
+
# unrouted by design — probing it directly would fail this check for
|
|
65
|
+
# a URL the client never requests on its own.
|
|
66
|
+
w = Paubox::WebhooksClient
|
|
67
|
+
puts "#{w::WEBHOOKS_PROTOCOL}#{w::WEBHOOKS_HOST}#{w::WEBHOOKS_BASE}/endpoints"
|
|
61
68
|
' | .github/scripts/route-check.sh
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.0.0](https://github.com/Paubox/paubox-ruby/compare/v1.3.1...v2.0.0) (2026-10-14)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### ⚠ BREAKING CHANGES
|
|
9
|
+
|
|
10
|
+
* move webhook CRUD to the pb_webhooks service ([#36](https://github.com/Paubox/paubox-ruby/issues/36))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* move webhook CRUD to the pb_webhooks service ([#36](https://github.com/Paubox/paubox-ruby/issues/36)) ([a27d3b6](https://github.com/Paubox/paubox-ruby/commit/a27d3b6c2643d21368235077e7a92cf28da3d7e4))
|
|
15
|
+
|
|
5
16
|
## [1.3.1](https://github.com/Paubox/paubox-ruby/compare/v1.3.0...v1.3.1) (2026-10-02)
|
|
6
17
|
|
|
7
18
|
|
data/README.md
CHANGED
|
@@ -497,6 +497,117 @@ pdf = client.submission_pdf('550e8400-e29b-41d4-a716-446655440000',
|
|
|
497
497
|
File.binwrite('submission.pdf', pdf)
|
|
498
498
|
```
|
|
499
499
|
|
|
500
|
+
<a name="#webhooks"></a>
|
|
501
|
+
## Webhooks
|
|
502
|
+
|
|
503
|
+
A webhook endpoint is a URL you own that Paubox notifies when something happens.
|
|
504
|
+
`Paubox::WebhooksClient` manages those subscriptions: which URL to notify, and for which events.
|
|
505
|
+
|
|
506
|
+
Every method needs an API key scoped for the events you want, sent as an `Authorization: Bearer`
|
|
507
|
+
header — not the Email API's `Token token=`. Pass it to the constructor or configure it globally:
|
|
508
|
+
|
|
509
|
+
```ruby
|
|
510
|
+
require 'Paubox'
|
|
511
|
+
|
|
512
|
+
client = Paubox::WebhooksClient.new(api_key: ENV['PAUBOX_WEBHOOKS_API_KEY'])
|
|
513
|
+
|
|
514
|
+
# or globally:
|
|
515
|
+
Paubox.configure { |config| config.webhooks_api_key = ENV['PAUBOX_WEBHOOKS_API_KEY'] }
|
|
516
|
+
client = Paubox::WebhooksClient.new
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Which events a key may subscribe to follows from its scopes. The SDK does not inspect them — the
|
|
520
|
+
service refuses an event the key isn't scoped for with 403, and an unrecognised event with 422.
|
|
521
|
+
|
|
522
|
+
**Available events**
|
|
523
|
+
|
|
524
|
+
| Event | Fires when |
|
|
525
|
+
| --- | --- |
|
|
526
|
+
| `forms.submission.created` | A form submission is received |
|
|
527
|
+
| `api_mail_log_delivered` | An Email API message is delivered |
|
|
528
|
+
| `api_mail_log_opened` | A delivered message is opened |
|
|
529
|
+
| `api_mail_log_temporary_failure` | Delivery fails temporarily |
|
|
530
|
+
| `api_mail_log_permanent_failure` | Delivery fails permanently |
|
|
531
|
+
| `email.inbound.received` | Inbound mail is received |
|
|
532
|
+
|
|
533
|
+
The four Email API events keep their legacy `api_mail_log_` names, so a receiver written
|
|
534
|
+
against the old Email API webhooks keeps working unchanged. Note these are the names on
|
|
535
|
+
the wire: `delivered` or `failed` on their own are rejected as unknown.
|
|
536
|
+
|
|
537
|
+
The SDK does not check these names. The catalog lives in the service, so an event added
|
|
538
|
+
later works here without upgrading the SDK.
|
|
539
|
+
|
|
540
|
+
### Creating a webhook endpoint
|
|
541
|
+
|
|
542
|
+
```ruby
|
|
543
|
+
endpoint = client.create_webhook_endpoint(
|
|
544
|
+
target_url: 'https://example.com/paubox-webhook',
|
|
545
|
+
events: ['forms.submission.created']
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
endpoint['id'] # UUID
|
|
549
|
+
endpoint['signing_secret'] # whsec_... — store this now
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
`signing_secret` is returned **once**, here. It is absent from `get_webhook_endpoint` and
|
|
553
|
+
`list_webhook_endpoints`, and there is no way to read it back — recovering from a lost secret
|
|
554
|
+
means deleting the endpoint and creating a new one. Use it to verify that deliveries really came
|
|
555
|
+
from Paubox.
|
|
556
|
+
|
|
557
|
+
### Listing webhook endpoints
|
|
558
|
+
|
|
559
|
+
```ruby
|
|
560
|
+
result = client.list_webhook_endpoints
|
|
561
|
+
|
|
562
|
+
result['data'].each do |endpoint|
|
|
563
|
+
puts [endpoint['id'], endpoint['target_url'], endpoint['status']].join(' ')
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
result['page_info']['count'] # total matching, not the length of this page
|
|
567
|
+
|
|
568
|
+
# Paginated
|
|
569
|
+
client.list_webhook_endpoints(page: 2, items: 25)
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### Getting, updating and deleting
|
|
573
|
+
|
|
574
|
+
```ruby
|
|
575
|
+
endpoint = client.get_webhook_endpoint('2ec66c21-bf48-48eb-8d28-f80b2d6b77c7')
|
|
576
|
+
|
|
577
|
+
# Only the keyword arguments you pass are sent
|
|
578
|
+
endpoint = client.update_webhook_endpoint(id, status: 'disabled')
|
|
579
|
+
|
|
580
|
+
client.delete_webhook_endpoint(id) # => true
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Pausing deliveries without losing the subscription is what `disabled` is for; pass `active` to
|
|
584
|
+
resume. Passing `events:` replaces the list rather than adding to it. Deleting answers `204 No
|
|
585
|
+
Content`, stops every event on that endpoint, and takes the signing secret with it.
|
|
586
|
+
|
|
587
|
+
### Error handling
|
|
588
|
+
|
|
589
|
+
A non-2xx response raises `Paubox::WebhooksClient::Error`, carrying the service's own message
|
|
590
|
+
rather than a bare status:
|
|
591
|
+
|
|
592
|
+
```ruby
|
|
593
|
+
begin
|
|
594
|
+
client.create_webhook_endpoint(target_url: url, events: ['forms.submission.created'])
|
|
595
|
+
rescue Paubox::WebhooksClient::Error => e
|
|
596
|
+
e.status_code # 422
|
|
597
|
+
e.message # "target_url: an endpoint already exists for this URL"
|
|
598
|
+
e.response_body # raw response
|
|
599
|
+
e.url
|
|
600
|
+
end
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
The error deliberately carries only those three fields, so an error reporter capturing locals from
|
|
604
|
+
the raising frame never picks up the `Authorization` header or the API key.
|
|
605
|
+
|
|
606
|
+
Two cases worth handling explicitly: subscribing a `target_url` that already has an endpoint comes
|
|
607
|
+
back as 422, and an id that doesn't belong to your account is a 404 rather than a 403. An id that
|
|
608
|
+
isn't a UUID raises `ArgumentError` locally, before any request is made, as does calling any
|
|
609
|
+
method without an API key.
|
|
610
|
+
|
|
500
611
|
<a name="#contributing"></a>
|
|
501
612
|
## Contributing
|
|
502
613
|
|
data/api.md
CHANGED
|
@@ -281,3 +281,217 @@ client.submit_form('550e8400-e29b-41d4-a716-446655440000',
|
|
|
281
281
|
{ name: 'consent.pdf', content: Base64.strict_encode64(File.binread('consent.pdf')) }
|
|
282
282
|
])
|
|
283
283
|
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Webhooks API
|
|
288
|
+
|
|
289
|
+
A webhook endpoint is a URL you own that Paubox notifies when something happens. These endpoints
|
|
290
|
+
manage those subscriptions.
|
|
291
|
+
|
|
292
|
+
### Authentication
|
|
293
|
+
|
|
294
|
+
An API key scoped for the events you want, sent as a Bearer header:
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
Authorization: Bearer <SCOPED_API_KEY>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Not the Email API's `Token token=`. Which events a key may subscribe to follows from its scopes;
|
|
301
|
+
the SDK does not inspect them, so an event the key is not scoped for returns 403 and an
|
|
302
|
+
unrecognised one 422.
|
|
303
|
+
|
|
304
|
+
**Available events**
|
|
305
|
+
|
|
306
|
+
| Event | Fires when |
|
|
307
|
+
| --- | --- |
|
|
308
|
+
| `forms.submission.created` | A form submission is received |
|
|
309
|
+
| `api_mail_log_delivered` | An Email API message is delivered |
|
|
310
|
+
| `api_mail_log_opened` | A delivered message is opened |
|
|
311
|
+
| `api_mail_log_temporary_failure` | Delivery fails temporarily |
|
|
312
|
+
| `api_mail_log_permanent_failure` | Delivery fails permanently |
|
|
313
|
+
| `email.inbound.received` | Inbound mail is received |
|
|
314
|
+
|
|
315
|
+
The four Email API events keep their legacy `api_mail_log_` names, so a receiver written
|
|
316
|
+
against the old Email API webhooks keeps working unchanged. Note these are the names on
|
|
317
|
+
the wire: `delivered` or `failed` on their own are rejected as unknown.
|
|
318
|
+
|
|
319
|
+
The SDK does not check these names. The catalog lives in the service, so an event added
|
|
320
|
+
later works here without upgrading the SDK.
|
|
321
|
+
|
|
322
|
+
### Base URL
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
https://api.paubox.com/v1/webhooks
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Every request carries the `/endpoints` resource. The bare base is unrouted on the public gateway
|
|
329
|
+
by design: the rule is scoped to `/endpoints` so the producers' event-ingest route stays private.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
### List Webhook Endpoints
|
|
334
|
+
|
|
335
|
+
**GET** `/endpoints`
|
|
336
|
+
|
|
337
|
+
Returns the endpoints this key can act on. Endpoints carrying an event the key is not scoped for
|
|
338
|
+
are filtered out by the service.
|
|
339
|
+
|
|
340
|
+
**Query parameters:**
|
|
341
|
+
- `page` (integer, optional) — 1-based page number
|
|
342
|
+
- `items` (integer, optional) — page size
|
|
343
|
+
|
|
344
|
+
**Response (200):**
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"data": [
|
|
348
|
+
{
|
|
349
|
+
"id": "2ec66c21-bf48-48eb-8d28-f80b2d6b77c7",
|
|
350
|
+
"target_url": "https://example.com/paubox-webhook",
|
|
351
|
+
"status": "active",
|
|
352
|
+
"events": ["forms.submission.created"],
|
|
353
|
+
"created_at": "2026-10-09T01:43:41.639968+00:00",
|
|
354
|
+
"updated_at": "2026-10-09T01:43:41.639968+00:00"
|
|
355
|
+
}
|
|
356
|
+
],
|
|
357
|
+
"page_info": { "count": 1, "items": 50 }
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`page_info.count` is the total number of matching endpoints, not the length of `data`.
|
|
362
|
+
|
|
363
|
+
**Ruby usage:**
|
|
364
|
+
```ruby
|
|
365
|
+
client = Paubox::WebhooksClient.new(api_key: ENV['PAUBOX_WEBHOOKS_API_KEY'])
|
|
366
|
+
|
|
367
|
+
result = client.list_webhook_endpoints
|
|
368
|
+
result = client.list_webhook_endpoints(page: 2, items: 25)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Returns the full document — `data` plus `page_info` — since a caller paginating needs both.
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
### Create Webhook Endpoint
|
|
376
|
+
|
|
377
|
+
**POST** `/endpoints`
|
|
378
|
+
|
|
379
|
+
**Request body:**
|
|
380
|
+
- `target_url` (string, required) — an https URL resolving to a publicly routable address
|
|
381
|
+
- `events` (array of strings, required, non-empty)
|
|
382
|
+
|
|
383
|
+
**Response (201):**
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"data": {
|
|
387
|
+
"id": "2ec66c21-bf48-48eb-8d28-f80b2d6b77c7",
|
|
388
|
+
"target_url": "https://example.com/paubox-webhook",
|
|
389
|
+
"status": "active",
|
|
390
|
+
"events": ["forms.submission.created"],
|
|
391
|
+
"created_at": "2026-10-09T01:43:41.639968+00:00",
|
|
392
|
+
"updated_at": "2026-10-09T01:43:41.639968+00:00",
|
|
393
|
+
"signing_secret": "whsec_..."
|
|
394
|
+
},
|
|
395
|
+
"message": "Store this signing_secret now — it is not shown again."
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`signing_secret` is returned only here. It is absent from get and list, and cannot be read back:
|
|
400
|
+
recovering from a lost secret means replacing the endpoint.
|
|
401
|
+
|
|
402
|
+
**Errors:**
|
|
403
|
+
- `422` — missing or malformed `target_url`, an empty `events`, an unrecognised event, or a
|
|
404
|
+
`target_url` that already has an endpoint
|
|
405
|
+
- `403` — an event this key is not scoped for
|
|
406
|
+
|
|
407
|
+
**Ruby usage:**
|
|
408
|
+
```ruby
|
|
409
|
+
endpoint = client.create_webhook_endpoint(
|
|
410
|
+
target_url: 'https://example.com/paubox-webhook',
|
|
411
|
+
events: ['forms.submission.created']
|
|
412
|
+
)
|
|
413
|
+
endpoint['signing_secret']
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Returns the endpoint itself, unwrapped from the `data` envelope.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
### Get Webhook Endpoint
|
|
421
|
+
|
|
422
|
+
**GET** `/endpoints/<id>`
|
|
423
|
+
|
|
424
|
+
**Path parameter:**
|
|
425
|
+
- `id` (UUID, required)
|
|
426
|
+
|
|
427
|
+
**Response (200):** the endpoint, wrapped in `data`. The signing secret is not included.
|
|
428
|
+
|
|
429
|
+
**Errors:**
|
|
430
|
+
- `404` — no such endpoint, or it belongs to another account
|
|
431
|
+
|
|
432
|
+
**Ruby usage:**
|
|
433
|
+
```ruby
|
|
434
|
+
endpoint = client.get_webhook_endpoint('2ec66c21-bf48-48eb-8d28-f80b2d6b77c7')
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
An id that is not UUID-shaped raises `ArgumentError` before any request is sent.
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
### Update Webhook Endpoint
|
|
442
|
+
|
|
443
|
+
**PATCH** `/endpoints/<id>`
|
|
444
|
+
|
|
445
|
+
A partial update: only the fields sent are changed.
|
|
446
|
+
|
|
447
|
+
**Request body (all optional, at least one required):**
|
|
448
|
+
- `target_url` (string)
|
|
449
|
+
- `status` (string) — `"active"` or `"disabled"`
|
|
450
|
+
- `events` (array of strings) — replaces the list rather than adding to it
|
|
451
|
+
|
|
452
|
+
**Response (200):** the updated endpoint, wrapped in `data`.
|
|
453
|
+
|
|
454
|
+
**Ruby usage:**
|
|
455
|
+
```ruby
|
|
456
|
+
client.update_webhook_endpoint(id, status: 'disabled')
|
|
457
|
+
client.update_webhook_endpoint(id, target_url: 'https://example.com/new-hook')
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`disabled` pauses deliveries without losing the subscription; `active` resumes them.
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
### Delete Webhook Endpoint
|
|
465
|
+
|
|
466
|
+
**DELETE** `/endpoints/<id>`
|
|
467
|
+
|
|
468
|
+
**Response:**
|
|
469
|
+
- `204` — deleted, no body
|
|
470
|
+
- `404` — no such endpoint, or it belongs to another account
|
|
471
|
+
|
|
472
|
+
Deleting stops every event on that endpoint and discards its signing secret.
|
|
473
|
+
|
|
474
|
+
**Ruby usage:**
|
|
475
|
+
```ruby
|
|
476
|
+
client.delete_webhook_endpoint(id) # => true
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
### Webhook Errors
|
|
482
|
+
|
|
483
|
+
Every non-2xx response raises `Paubox::WebhooksClient::Error`:
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
begin
|
|
487
|
+
client.get_webhook_endpoint(id)
|
|
488
|
+
rescue Paubox::WebhooksClient::Error => e
|
|
489
|
+
e.status_code # Integer
|
|
490
|
+
e.message # the service's own message, e.g. "webhook endpoint not found"
|
|
491
|
+
e.url
|
|
492
|
+
e.response_body # raw response
|
|
493
|
+
end
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
The error carries only those fields, so an error reporter capturing locals from the raising frame
|
|
497
|
+
never sees the `Authorization` header or the API key.
|
data/lib/paubox/client.rb
CHANGED
|
@@ -163,38 +163,6 @@ module Paubox
|
|
|
163
163
|
RestClient.get(url, auth_header.merge(accept: '*/*'))
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
-
def list_webhook_endpoints
|
|
167
|
-
url = request_endpoint('webhook_endpoints')
|
|
168
|
-
response = RestClient.get(url, auth_header)
|
|
169
|
-
JSON.parse(response.body)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def create_webhook_endpoint(target_url:, events:, signing_key: nil, api_key: nil, active: true)
|
|
173
|
-
url = request_endpoint('webhook_endpoints')
|
|
174
|
-
payload = { target_url: target_url, events: events, active: active,
|
|
175
|
-
signing_key: signing_key, api_key: api_key }.compact.to_json
|
|
176
|
-
response = RestClient.post(url, payload, auth_header)
|
|
177
|
-
JSON.parse(response.body)
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def get_webhook_endpoint(id)
|
|
181
|
-
url = request_endpoint("webhook_endpoints/#{id}")
|
|
182
|
-
response = RestClient.get(url, auth_header)
|
|
183
|
-
JSON.parse(response.body)
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def update_webhook_endpoint(id, **params)
|
|
187
|
-
url = request_endpoint("webhook_endpoints/#{id}")
|
|
188
|
-
response = RestClient.patch(url, params.to_json, auth_header)
|
|
189
|
-
JSON.parse(response.body)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def delete_webhook_endpoint(id)
|
|
193
|
-
url = request_endpoint("webhook_endpoints/#{id}")
|
|
194
|
-
response = RestClient.delete(url, auth_header)
|
|
195
|
-
JSON.parse(response.body)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
166
|
def send_request(method: :get, payload: {}, path: '')
|
|
199
167
|
url = request_endpoint(path)
|
|
200
168
|
|
data/lib/paubox/version.rb
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'rest-client'
|
|
6
|
+
|
|
7
|
+
module Paubox
|
|
8
|
+
# Client for the Paubox webhooks service.
|
|
9
|
+
#
|
|
10
|
+
# Authenticates with a scoped API key sent as "Authorization: Bearer <key>",
|
|
11
|
+
# the same scheme as FormsClient and unlike the Email API's "Token token=".
|
|
12
|
+
# Which events a key may subscribe to is decided by its scopes; asking for
|
|
13
|
+
# one it is not scoped for is refused with 403.
|
|
14
|
+
class WebhooksClient
|
|
15
|
+
WEBHOOKS_HOST = 'api.paubox.com'
|
|
16
|
+
WEBHOOKS_PROTOCOL = 'https://'
|
|
17
|
+
# The public gateway exposes only /v1/webhooks/endpoints and rewrites it
|
|
18
|
+
# onto the service's own /v1/endpoints, so the producers' event-ingest
|
|
19
|
+
# route is unreachable from here. The bare base is deliberately unrouted;
|
|
20
|
+
# every request below carries the /endpoints resource.
|
|
21
|
+
WEBHOOKS_BASE = '/v1/webhooks'
|
|
22
|
+
|
|
23
|
+
TIMEOUT_SECONDS = 30
|
|
24
|
+
|
|
25
|
+
UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
26
|
+
|
|
27
|
+
# Raised on any non-2xx response. Carries only status_code / url /
|
|
28
|
+
# response_body so error reporters that capture raising-frame locals never
|
|
29
|
+
# see the Authorization header or api_key.
|
|
30
|
+
class Error < StandardError
|
|
31
|
+
attr_reader :status_code, :url, :response_body
|
|
32
|
+
|
|
33
|
+
def initialize(message, status_code: nil, url: nil, response_body: nil)
|
|
34
|
+
super(message)
|
|
35
|
+
@status_code = status_code
|
|
36
|
+
@url = url
|
|
37
|
+
@response_body = response_body
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(args = {})
|
|
42
|
+
@host = args[:host] || WEBHOOKS_HOST
|
|
43
|
+
@protocol = args[:protocol] || WEBHOOKS_PROTOCOL
|
|
44
|
+
@base = args[:base] || WEBHOOKS_BASE
|
|
45
|
+
@api_key = args[:api_key] ||
|
|
46
|
+
(Paubox.configuration && Paubox.configuration.webhooks_api_key)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Lists the endpoints this key can act on. Endpoints carrying an event the
|
|
50
|
+
# key is not scoped for are filtered out by the service, so this is what
|
|
51
|
+
# the key may manage rather than everything on the account.
|
|
52
|
+
#
|
|
53
|
+
# Returns the full document: 'data' plus 'page_info'. Note
|
|
54
|
+
# page_info['count'] is the total number of matching endpoints, not the
|
|
55
|
+
# length of this page.
|
|
56
|
+
def list_webhook_endpoints(page: nil, items: nil)
|
|
57
|
+
query = { page: page, items: items }.compact
|
|
58
|
+
url = endpoint_url
|
|
59
|
+
url = "#{url}?#{URI.encode_www_form(query)}" unless query.empty?
|
|
60
|
+
|
|
61
|
+
parse(request(method: :get, url: url, headers: auth_headers))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Subscribes a URL to one or more events.
|
|
65
|
+
#
|
|
66
|
+
# The returned hash carries 'signing_secret', which the service sends only
|
|
67
|
+
# here — it is absent from get_webhook_endpoint and from the list. Store it
|
|
68
|
+
# on receipt; a lost secret means replacing the endpoint.
|
|
69
|
+
#
|
|
70
|
+
# Event names are not validated here on purpose: the catalog belongs to the
|
|
71
|
+
# service and grows without a gem release. An unknown event comes back as
|
|
72
|
+
# 422, one this key is not scoped for as 403.
|
|
73
|
+
def create_webhook_endpoint(target_url:, events:)
|
|
74
|
+
raise ArgumentError, 'target_url is required' if target_url.nil? || target_url.to_s.empty?
|
|
75
|
+
raise ArgumentError, 'events must not be empty' if events.nil? || Array(events).empty?
|
|
76
|
+
|
|
77
|
+
payload = { target_url: target_url, events: Array(events) }.to_json
|
|
78
|
+
response = request(
|
|
79
|
+
method: :post, url: endpoint_url,
|
|
80
|
+
headers: auth_headers(content_type: :json), payload: payload
|
|
81
|
+
)
|
|
82
|
+
unwrap_data(parse(response))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def get_webhook_endpoint(id)
|
|
86
|
+
assert_uuid!(id, 'id')
|
|
87
|
+
unwrap_data(parse(request(method: :get, url: endpoint_url(id), headers: auth_headers)))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Partial update: only the arguments given are sent, so changing
|
|
91
|
+
# target_url leaves events and status untouched. status accepts
|
|
92
|
+
# 'active' or 'disabled'.
|
|
93
|
+
def update_webhook_endpoint(id, target_url: nil, status: nil, events: nil)
|
|
94
|
+
assert_uuid!(id, 'id')
|
|
95
|
+
|
|
96
|
+
body = {}
|
|
97
|
+
body[:target_url] = target_url unless target_url.nil?
|
|
98
|
+
body[:status] = status unless status.nil?
|
|
99
|
+
body[:events] = Array(events) unless events.nil?
|
|
100
|
+
raise ArgumentError, 'provide at least one of target_url:, status:, events:' if body.empty?
|
|
101
|
+
|
|
102
|
+
response = request(
|
|
103
|
+
method: :patch, url: endpoint_url(id),
|
|
104
|
+
headers: auth_headers(content_type: :json), payload: body.to_json
|
|
105
|
+
)
|
|
106
|
+
unwrap_data(parse(response))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Deletes an endpoint, stopping every event on it. The service answers 204
|
|
110
|
+
# with no body, so this returns true rather than a document.
|
|
111
|
+
def delete_webhook_endpoint(id)
|
|
112
|
+
assert_uuid!(id, 'id')
|
|
113
|
+
request(method: :delete, url: endpoint_url(id), headers: auth_headers)
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def endpoint_url(id = nil)
|
|
120
|
+
base = "#{@protocol}#{@host}#{@base}/endpoints"
|
|
121
|
+
id.nil? ? base : "#{base}/#{encode_segment(id)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def auth_headers(extra = {})
|
|
125
|
+
if @api_key.nil? || @api_key.to_s.empty?
|
|
126
|
+
raise ArgumentError, 'api_key is required for this endpoint. Pass a scoped ' \
|
|
127
|
+
'API key: Paubox::WebhooksClient.new(api_key: ...), or set ' \
|
|
128
|
+
'Paubox.configuration.webhooks_api_key'
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
{ authorization: "Bearer #{@api_key}", accept: :json }.merge(extra)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def assert_uuid!(value, name)
|
|
135
|
+
return if value.is_a?(String) && value.match?(UUID_RE)
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, "#{name} must be a UUID string (received #{value.inspect})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def encode_segment(value)
|
|
141
|
+
CGI.escape(value.to_s)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse(response)
|
|
145
|
+
body = response.body.to_s
|
|
146
|
+
body.empty? ? nil : JSON.parse(body)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Single resources arrive wrapped as {"data" => {...}}. Unwrapped so
|
|
150
|
+
# callers get the endpoint rather than an envelope carrying nothing else.
|
|
151
|
+
#
|
|
152
|
+
# list_webhook_endpoints deliberately skips this: its envelope also carries
|
|
153
|
+
# 'page_info', which a caller paginating needs.
|
|
154
|
+
def unwrap_data(parsed)
|
|
155
|
+
parsed.is_a?(Hash) && parsed.key?('data') ? parsed['data'] : parsed
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Funnels every request through RestClient::Request.execute so we can
|
|
159
|
+
# always set an explicit timeout (rest-client's default is nil) and
|
|
160
|
+
# translate ExceptionWithResponse into Paubox::WebhooksClient::Error,
|
|
161
|
+
# which carries only status_code / url / response_body — never the
|
|
162
|
+
# Authorization header or api_key. Keeps the Bearer token out of any error
|
|
163
|
+
# reporter that captures raising-frame locals.
|
|
164
|
+
#
|
|
165
|
+
# Unlike FormsClient the service's own message is promoted onto the
|
|
166
|
+
# exception when present, so a caller sees "target_url: must be an https
|
|
167
|
+
# URL" rather than only a status code.
|
|
168
|
+
def request(method:, url:, headers: {}, payload: nil)
|
|
169
|
+
RestClient::Request.execute(
|
|
170
|
+
method: method, url: url, payload: payload,
|
|
171
|
+
headers: headers, timeout: TIMEOUT_SECONDS
|
|
172
|
+
)
|
|
173
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
174
|
+
response = e.response
|
|
175
|
+
code = response && response.code
|
|
176
|
+
raise Error.new(
|
|
177
|
+
service_message(response) ||
|
|
178
|
+
"Paubox webhooks API request failed: #{e.class.name.split('::').last} " \
|
|
179
|
+
"(status #{code || 'unknown'})",
|
|
180
|
+
status_code: code,
|
|
181
|
+
url: url,
|
|
182
|
+
response_body: response && response.body
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The service answers failures as {"message": "..."}; anything else falls
|
|
187
|
+
# back to the generic message above.
|
|
188
|
+
def service_message(response)
|
|
189
|
+
return nil unless response
|
|
190
|
+
|
|
191
|
+
parsed = JSON.parse(response.body.to_s)
|
|
192
|
+
parsed.is_a?(Hash) && parsed['message'].is_a?(String) ? parsed['message'] : nil
|
|
193
|
+
rescue JSON::ParserError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
data/lib/paubox.rb
CHANGED
|
@@ -11,6 +11,7 @@ require 'paubox/email_disposition'
|
|
|
11
11
|
require 'paubox/form'
|
|
12
12
|
require 'paubox/form_submission'
|
|
13
13
|
require 'paubox/forms_client'
|
|
14
|
+
require 'paubox/webhooks_client'
|
|
14
15
|
require 'mail/paubox'
|
|
15
16
|
|
|
16
17
|
module Paubox
|
|
@@ -24,7 +25,7 @@ module Paubox
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
class Configuration
|
|
27
|
-
attr_accessor :api_key, :forms_api_key
|
|
28
|
+
attr_accessor :api_key, :forms_api_key, :webhooks_api_key
|
|
28
29
|
|
|
29
30
|
# Deprecated: api_user is no longer used by the Email API client.
|
|
30
31
|
# Kept only for backward compatibility; it has no effect on requests.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: paubox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paubox
|
|
@@ -178,6 +178,7 @@ files:
|
|
|
178
178
|
- lib/paubox/message.rb
|
|
179
179
|
- lib/paubox/templated_message.rb
|
|
180
180
|
- lib/paubox/version.rb
|
|
181
|
+
- lib/paubox/webhooks_client.rb
|
|
181
182
|
- lib/paubox_ruby.rb
|
|
182
183
|
- paubox_ruby.gemspec
|
|
183
184
|
- release-please-config.json
|