linkExample scenario
We want to implement self-serve license creation, with automated billing. We also want to assert that a user is only ever charged once per-license, even if our webhook server receives the same event twice (either due to retries, or other reasons.)
To accomplish this — when a user creates a new license, we'd want to invoice them for the newly created license. We could do this by updating a line item quantity on a monthly invoice, or by charging them right away.
We can listen for license creation events using the license.created
webhook.
But we want to make sure that the license.created
webhook event that we're
listening is only acted upon once, regardless of how many times we might
retry the event. This
guarantees that we only charge the user once for a given license.
We can accomplish this by logging the idempotency token (to a datastore, for example, Redis) while processing the event, and ignoring any future webhook events that come through with an identical token, signaling a retried event. Typically, the token can have a TTL of a few days and be fine, but this may need to be increased.
With Redis, this would look something like this:
SET {meta.idempotencyToken} 1 EX 258000 NX
If the command returns 1
, then the event can be processed. If it returns 0
,
then the event has already been processed. Of course, you should remove the
lock upon error so that retries can be performed on unprocessed events.
Example resource
A failed webhook event resource.
{ "data": { "id": "2bc99fe1-f315-4877-ac5f-542240c4e883", "type": "webhook-events", "meta": { "idempotencyToken": "e8e0fbb598e8bcfd0e94ceb79199edc79e6ab53f4a4bbb32d7aede7964e7c3v2", }, "links": { "self": "/v1/accounts/<account>/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883" }, "attributes": { "endpoint": "https://example.com/webhooks", "payload": "{\"data\":{…}}", "event": "license.created", "status": "failed", "lastResponseCode": 500, "lastResponseBody": "Internal Server Error", "created": "2017-01-02T20:26:53.464Z", "updated": "2017-01-02T20:26:53.464Z" }, "relationships": { "account": { "links": { "related": "/v1/accounts/<account>" }, "data": { "type": "accounts", "id": "<account>" } } } }}
Example request
Retry the above failed webhook event resource.
const fetch = require("node-fetch")const response = await fetch("https://api.keygen.sh/v1/accounts/<account>/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry", {method: "POST",headers: {"Accept": "application/vnd.api+json","Authorization": "Bearer <token>"}})const { data, errors } = await response.json()
import requestsimport jsonres = requests.post("https://api.keygen.sh/v1/accounts/<account>/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry",headers={"Accept": "application/vnd.api+json","Authorization": "Bearer <token>"}).json()
import SwiftyJSONimport AlamofireAlamofire.request("https://api.keygen.sh/v1/accounts/<account>/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry",method: .post,headers: ["Accept": "application/vnd.api+json","Authorization": "Bearer <token>"]).responseJSON { response inlet json = JSON(data: response.data!)}
using RestSharp;var client = new RestClient("https://api.keygen.sh/v1/accounts/<account>");var request = new RestRequest("webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry",Method.POST);request.AddHeader("Accept", "application/vnd.api+json");request.AddHeader("Authorization", "Bearer <token>");var response = client.Execute(request);
import com.mashape.unirest.http.exceptions.*import com.mashape.unirest.http.*val res = Unirest.post("https://api.keygen.sh/v1/accounts/<account>/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry").header("Authorization", "Bearer <token>").header("Accept", "application/vnd.api+json").asJson()
import com.mashape.unirest.http.exceptions.*;import com.mashape.unirest.http.*;HttpResponse<JsonNode> res = Unirest.post("https://api.keygen.sh/v1/accounts/<account>/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry").header("Authorization", "Bearer <token>").header("Accept", "application/vnd.api+json").asJson();
#include <iostream>#include <string>#include <cpprest/http_client.h>#include <cpprest/filestream.h>using namespace std;using namespace web;using namespace web::http;using namespace web::http::client;using namespace utility;http_client client("https://api.keygen.sh/v1/accounts/<account>");http_request req;req.headers().add("Authorization", "Bearer <token>");req.headers().add("Accept", "application/json");req.set_request_uri("/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry");req.set_method(methods::POST);client.request(req).then([](http_response res) {auto data = res.extract_json().get();}).wait();
curl -X POST https://api.keygen.sh/v1/accounts/<account>/webhook-events/2bc99fe1-f315-4877-ac5f-542240c4e883/actions/retry \-H 'Accept: application/vnd.api+json' \-H 'Authorization: Bearer <token>'
Example response
Notice that it's a new resource, yet the idempotency token matches the original event.
{ "data": { "id": "1bda5fd5-6d82-49f5-b5b8-71bb432a32bb", "type": "webhook-events", "meta": { "idempotencyToken": "e8e0fbb598e8bcfd0e94ceb79199edc79e6ab53f4a4bbb32d7aede7964e7c3v2", }, "links": { "self": "/v1/accounts/<account>/webhook-events/1bda5fd5-6d82-49f5-b5b8-71bb432a32bb" }, "attributes": { "endpoint": "https://example.com/webhooks", "payload": "{\"data\":{…}}", "event": "license.created", "status": "DELIVERING", "lastResponseCode": null, "lastResponseBody": null, "created": "2017-01-02T20:26:53.464Z", "updated": "2017-01-02T20:26:53.464Z" }, "relationships": { "account": { "links": { "related": "/v1/accounts/<account>" }, "data": { "type": "accounts", "id": "<account>" } } } }}