Select programming language for code examples

linkIdempotency

Some resources, such as webhook events may contain an idempotency token inside of a meta object. In the case of webhook events, these tokens allow you to safely retry events without accidentally responding to the same event multiple times.

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.

Although retrying a webhook event creates a new webhook event resource, the idempotency token will stay the same throughout the event's lifetime. Meaning, you can safely retry an event if you're paying attention to idempotency tokens.

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 requests
import json
 
res = 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 SwiftyJSON
import Alamofire
 
Alamofire.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 in
let 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}"
}
}
}
}
}