Callbacks - Webhooks
Prerequisites
A pair of API key ID and API key secret.
Signature processing in Web Trust happens in an asynchronous way. Meaning that the API call you make to create a signature will immediately return a signature in status SUBSCRIBED.
So how do you know when the signature transits to another state, for instance CONCLUDED?
You have two ways to make this verification: polling or callbacks.
Polling
This is the most basic technique to check for anything API-related. You just check the signature every X seconds to look for the state you are interesting in.
Let’s code an example that tries to check for the CONCLUDED state using short-polling; unfortunately, you can not use long-polling to poll Web Trust.
async function callAPI(id: number): Promise<Signature> {
const res = await fecth(
`https://api.webtrust.kopjra.com/v1/signatures/${id}`,
{
"headers": {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw==",
},
"method": "GET"
}
);
// Avoiding Error management for simplicity
return await res.json();
}
async function waitForFinal(signatureId: number): Promise<Signature> {
return new Promise<Signature>(async (resolve, reject) => {
function pollEnded(signature: Signature): boolean {
if (
signature.currentStatus === "CONCLUDED" ||
signature.currentStatus === "REJECTED"
) {
resolve(signature);
return true;
} else if (
signature.currentStatus === "POD_FAILED" ||
signature.currentStatus === "OURL_FAILED" ||
signature.currentStatus === "CONCLUDING_FAILED_PERM"
) {
reject(new Error(`Signature ${signatureId} failed with status ${signature.currentStatus}`));
return true;
} else {
return false;
}
}
function poll() {
setTimeout(async () => {
if (!pollEnded(await callAPI(signatureId))) {
poll();
}
}, 60*1000); // Minimum checking interval 1 minute
}
if (!pollEnded(await callAPI(signatureId))) {
poll();
}
});
}using System;
using System.Net.Http;
using System.Threading.Tasks;
public class Signature
{
public string currentStatus { get; set; }
}
public class Program
{
private static readonly HttpClient client = new HttpClient();
private static readonly string authorization = "Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw==";
public static async Task<Signature> CallAPIAsync(int id)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.webtrust.kopjra.com/v1/signatures/{id}")
{
Headers =
{
{ "Content-Type", "application/json" },
{ "Accept", "application/json" },
{ "Authorization", authorization }
}
};
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsAsync<Signature>();
}
public static async Task<Signature> WaitForFinalAsync(int signatureId)
{
return await Task.Run(async () =>
{
bool pollEnded(Signature signature)
{
if (signature.currentStatus == "CONCLUDED" || signature.currentStatus == "REJECTED")
{
return true;
}
else if (signature.currentStatus == "POD_FAILED" || signature.currentStatus == "OURL_FAILED" || signature.currentStatus == "CONCLUDING_FAILED_PERM")
{
throw new Exception($"Signature {signatureId} failed with status {signature.currentStatus}");
}
else
{
return false;
}
}
async Task poll()
{
if (!pollEnded(await CallAPIAsync(signatureId)))
{
await Task.Delay(60 * 1000);
await poll();
}
}
if (!pollEnded(await CallAPIAsync(signatureId)))
{
await poll();
}
return await CallAPIAsync(signatureId);
});
}
public static async Task Main()
{
try
{
var signature = await WaitForFinalAsync(1);
Console.WriteLine(signature.currentStatus);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
}
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.List;
import java.util.concurrent.*;
public class Signature {
public String currentStatus;
}
public class Main {
private static final OkHttpClient client = new OkHttpClient();
private static final String authorization = "Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw==";
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public static void main(String[] args) throws InterruptedException, ExecutionException {
Future<Signature> future = callAPIAsync(1);
try {
Signature signature = future.get();
System.out.println(signature.currentStatus);
} catch (Exception e) {
System.out.println("Error: " + e.getMessage());
}
}
public static Future<Signature> callAPIAsync(int id) {
return CompletableFuture.supplyAsync(() -> {
try {
Request request = new Request.Builder()
.url("https://api.webtrust.kopjra.com/v1/signatures/" + id)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", authorization)
.get()
.build();
Response response = client.newCall(request).execute();
if (!response.isSuccessful()) {
throw new IOException("Unexpected code " + response);
}
Gson gson = new Gson();
Type type = new TypeToken<Signature>(){}.getType();
return gson.fromJson(response.body().string(), type);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
public static Future<Signature> waitForFinalAsync(int signatureId) {
return CompletableFuture.supplyAsync(() -> {
try {
boolean pollEnded(Signature signature) {
if (signature.currentStatus.equals("CONCLUDED") || signature.currentStatus.equals("REJECTED")) {
return true;
} else if (signature.currentStatus.equals("POD_FAILED") || signature.currentStatus.equals("OURL_FAILED") || signature.currentStatus.equals("CONCLUDING_FAILED_PERM")) {
throw new RuntimeException("Signature " + signatureId + " failed with status " + signature.currentStatus);
} else {
return false;
}
}
Signature signature = callAPIAsync(signatureId).get();
while (!pollEnded(signature)) {
Thread.sleep(60 * 1000);
signature = callAPIAsync(signatureId).get();
}
return signature;
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
}
import aiohttp
import asyncio
class Signature:
def __init__(self, current_status):
self.current_status = current_status
async def call_api(session, id):
async with session.get(f"https://api.webtrust.kopjra.com/v1/signatures/{id}",
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw=="
}) as response:
if response.status != 200:
raise Exception(f"Failed to fetch signature {id}")
data = await response.json()
return Signature(data['currentStatus'])
async def wait_for_final(signature_id):
async with aiohttp.ClientSession() as session:
async def poll_ended(signature):
if signature.current_status in ["CONCLUDED", "REJECTED"]:
return True
elif signature.current_status in ["POD_FAILED", "OURL_FAILED", "CONCLUDING_FAILED_PERM"]:
raise Exception(f"Signature {signature_id} failed with status {signature.current_status}")
else:
return False
async def poll():
signature = await call_api(session, signature_id)
if not await poll_ended(signature):
await asyncio.sleep(60)
await poll()
signature = await call_api(session, signature_id)
if not await poll_ended(signature):
await poll()
return signature
async def main():
try:
signature = await wait_for_final(1)
print(signature.current_status)
except Exception as e:
print(f"Error: {e}")
asyncio.run(main())
The waitForFinal function will return the signature when it is CONCLUDED or REJECTED, will throw an error for the other final states, and if the state is not final, it will retry to get the signature after a minute has passed.
However, this mechanism has certain pitfalls.
Signatures may take long to complete; consider that a signature has a life of a month. So you would be making several useless API calls.
Increasing the interval may make you wait more than needed.
Having several signatures active may lead to too many concurrent API calls, and you may surpass the call rate.
Callbacks (alias webhooks)
A better approach to being aware of state changes is to receive push notifications exactly when these changes happen. Web Trust implements a callback mechanism to tackle exactly this.
First of all, you need to register a callback.
const res = await fetch("https://api.webtrust.kopjra.com/v1/callbacks", {
"headers": {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw==",
},
"method": "POST",
"body": JSON.stringify({
"resourceType": "Signature",
"endpoint": "https://my.company.com/public/endpoint/post",
"onlyFinalStatuses": false,
"events": ["USER_CONCLUDED"],
});
console.log(await res.json());
});using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
class Program
{
private static readonly HttpClient client = new HttpClient();
static async Task Main(string[] args)
{
var url = "https://api.webtrust.kopjra.com/v1/callbacks";
var requestBody = new
{
resourceType = "Signature",
endpoint = "https://my.company.com/public/endpoint/post",
onlyFinalStatuses = false,
events = new[] { "USER_CONCLUDED" }
};
var requestContent = new StringContent(
Newtonsoft.Json.JsonConvert.SerializeObject(requestBody),
Encoding.UTF8,
"application/json"
);
client.DefaultRequestHeaders.Add("Authorization",
"Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw==");
var response = await client.PostAsync(url, requestContent);
var responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine(responseBody);
}
}
import okhttp3.*;
import com.google.gson.*;
import java.io.IOException;
public class Main {
public static void main(String[] args) throws IOException {
OkHttpClient client = new OkHttpClient();
String url = "https://api.webtrust.kopjra.com/v1/callbacks";
JsonObject requestBody = new JsonObject();
requestBody.addProperty("resourceType", "Signature");
requestBody.addProperty("endpoint", "https://my.company.com/public/endpoint/post");
requestBody.addProperty("onlyFinalStatuses", false);
JsonArray events = new JsonArray();
events.add("USER_CONCLUDED");
requestBody.add("events", events);
RequestBody body = RequestBody.create(
MediaType.parse("application/json"),
new Gson().toJson(requestBody)
);
Request request = new Request.Builder()
.url(url)
.post(body)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.addHeader("Authorization",
"Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw==")
.build();
Response response = client.newCall(request).execute();
System.out.println(response.body().string());
}
}
import requests
import json
url = "https://api.webtrust.kopjra.com/v1/callbacks"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": ("Basic NjhiOTkxMjEtYWIxNC00YzUwLWFlMzItNDgzZmQ4MWVkNWJkOnlBRG1YTDZ6IzNjVm4tWURPNmJsTWkybGY0MEpNMEdUcFpLVH5+Ujc1eUNEN3ZZVGU2fmJTdTJEOUZwNHFEVjl4RWhtVmRZSw=="),
}
data = {
"resourceType": "Signature",
"endpoint": "https://my.company.com/public/endpoint/post",
"onlyFinalStatuses": False,
"events": ["USER_CONCLUDED"],
}
response = requests.post(url, headers=headers, data=json.dumps(data))
print(response.json())
Let’s dive into the payload for a second.
resourceTypeindicates for which type of resources the endpoint will be called.Interactionis used for the Webpage and Data Forensic API, andSignatureis used for all the Document APIs.endpointis defined by you and will be called for each event. It should be public and listening to aPOST. In the body of thePOSTWeb Trust will send theSignature/Interactionthat changed state.onlyFinalStatuses, when set totrue, will reduce the callbacks to those corresponding to changes to final states. If set tofalseinstead, it will call theendpointfor all state changes.eventsis used to define a finer-grained callback that allows you to receive events not only when there are state changes but also when certain conditions are verified. Currently the only supported event isUSER_CONCLUDED. This event will be fired when the signer completes her actions in the signing environment, thus before the signature transits toCONCLUDING. This may be useful if you want to give some immediate feedback to the signer and don’t want to wait for theCONCLUDINGstate.
Beware that in order to specify the events property, onlyFinalStatuses must be false; otherwise, a 400 error will be thrown.
The response to the previous API call should be something like,
{
"id": 123,
"resourceType": "Interaction",
"endpoint": "https://my.company.com/public/endpoint/post",
"onlyFinalStatuses": false,
"events": [
"USER_CONCLUDED"
],
"secret": "whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH"
}endpoint should respond as quick as possible with a 200 to avoid automatic retries. So avoid blocking actions.
Save the secret in your database; you will need it afterwards to authenticate the calls.
Callback payload
Each callback will send the entire updated interaction or signature (depending on the resourceType indicated) as the payload. Using this payload you can get all the data you need and even make more GET requests to get other entities. An example of this payload can be found here.
Securing the endpoint
Since the endpoint must be public, it would be nice to have a way of securing it to avoid attacks.
Web Trust authenticates the callbacks using HMAC with SHA-256; this allows for you to verify the authenticity of the payload. This is optional but highly recommended.
Each callback is registered in SVIX to manage errors, retries, and security. Each POST to endpoint will include the following headers:
svix-id: the unique message identifier for the callback message. This identifier is unique across all messages but will be the same when the same callback is being resent (e.g., due to a previous failure).svix-timestamp: timestamp in seconds since epoch.svix-signature: the Base64-encoded list of signatures (space-delimited). For instance:v1,base64 v1,base64 v2,base64.
To verify the authenticity of the callback, one could implement the following in the endpoint:
import express from "express";
import {join} from "path";
import {createHmac} from "crypto";
const app = express();
const port = 3000;
const secret; // Read from your database. You got it when you created the callback.
...
app.post("/public/endpoint/post", (req, res) => {
// To this endpoint we will receive the callbacks
const headers = req.headers;
const svix_signatures_str = headers["svix-signature"];
const svix_id = headers["svix-id"];
const svix_timestamp = headers["svix-timestamp"];
if (svix_signatures_str && svix_id && svix_timestamp) {
// create a payload to be signed
const receivedData = `${svix_id}.${svix_timestamp}.${JSON.stringify(rep.body)}`;
// get the secret bytes
const secretBytes = new Buffer(secret.split('_')[1];
// signed the received payload with the secret you already have
const signature = createHmac(
"sha256",
secretBytes
).update(receivedData).digest('base64');
const svix_signatures = svix_signatures_str.split(" ");
if (svix_signatures.length > 0) {
for (const svix_signature of svix_signatures) {
const ss_b64 = svix_signature.split(",")[1];
// At least one of the svix_signatures must match
if (ss_b64 === signature) {
// AUTHENTICATED! so do what you had to do with this callback
break;
}
}
}
}
res.status(200).end(); // Always respond 2xx even in case of error
});
...
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
});using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
public class Startup
{
private readonly string secret; // Read from your database
public Startup(string secret)
{
this.secret = secret;
}
public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapPost("/public/endpoint/post", async context =>
{
var headers = context.Request.Headers;
var svixSignaturesStr = headers["svix-signature"];
var svixId = headers["svix-id"];
var svixTimestamp = headers["svix-timestamp"];
if (svixSignaturesStr != null && svixId != null && svixTimestamp != null)
{
var requestBody = await new StreamReader(context.Request.Body).ReadToEndAsync();
var receivedData = $"{svixId}.{svixTimestamp}.{requestBody}";
var secretBytes = Convert.FromHexString(secret.Split('_')[1]);
using var hmac = new HMACSHA256(secretBytes);
var signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(receivedData)));
var svixSignatures = svixSignaturesStr.Split(" ");
foreach (var svixSignature in svixSignatures)
{
var ssB64 = svixSignature.Split(",")[1];
if (ssB64 == signature)
{
// AUTHENTICATED! so do what you had to do with this callback
break;
}
}
}
context.Response.StatusCode = 200;
await context.Response.WriteAsync("");
});
});
}
}
public class Program
{
public static void Main(string[] args)
{
var secret = "your_secret_here";
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var startup = new Startup(secret);
startup.Configure(app, null);
app.Run();
}
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
@SpringBootApplication
@RestController
public class MainApplication {
private final String secret; // Read from your database
public MainApplication(String secret) {
this.secret = secret;
}
@PostMapping("/public/endpoint/post")
public ResponseEntity<String> handleCallback(
@RequestHeader("svix-signature") String svixSignaturesStr,
@RequestHeader("svix-id") String svixId,
@RequestHeader("svix-timestamp") String svixTimestamp,
@RequestBody String requestBody) {
if (svixSignaturesStr != null && svixId != null && svixTimestamp != null) {
String receivedData = svixId + "." + svixTimestamp + "." + requestBody;
byte[] secretBytes = java.util.HexFormat.of().parseHex(secret.split("_")[1]);
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretBytes, "HmacSHA256"));
byte[] signatureBytes = mac.doFinal(receivedData.getBytes(StandardCharsets.UTF_8));
String signature = Base64.getEncoder().encodeToString(signatureBytes);
String[] svixSignatures = svixSignaturesStr.split(" ");
for (String svixSignature : svixSignatures) {
String ssB64 = svixSignature.split(",")[1];
if (ssB64.equals(signature)) {
// AUTHENTICATED! so do what you had to do with this callback
break;
}
}
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
// Handle exceptions
}
}
return ResponseEntity.status(HttpStatus.OK).body("");
}
public static void main(String[] args) {
String secret = "your_secret_here";
SpringApplication.run(MainApplication.class, args);
}
}
from flask import Flask, request, jsonify
import hmac
import hashlib
import base64
app = Flask(__name__)
secret = "your_secret_here" # Read from your database
@app.route("/public/endpoint/post", methods=["POST"])
def handle_callback():
svix_signatures_str = request.headers.get("svix-signature")
svix_id = request.headers.get("svix-id")
svix_timestamp = request.headers.get("svix-timestamp")
if svix_signatures_str and svix_id and svix_timestamp:
request_body = request.get_data(as_text=True)
received_data = f"{svix_id}.{svix_timestamp}.{request_body}"
secret_bytes = bytes.fromhex(secret.split("_")[1])
signature = hmac.new(secret_bytes, received_data.encode('utf-8'), hashlib.sha256).digest()
signature_b64 = base64.b64encode(signature).decode('utf-8')
svix_signatures = svix_signatures_str.split(" ")
for svix_signature in svix_signatures:
ss_b64 = svix_signature.split(",")[1]
if ss_b64 == signature_b64:
# AUTHENTICATED! so do what you had to do with this callback
break
return "", 200
if __name__ == "__main__":
app.run(port=3000)
Management and retries
Web Trust callbacks are managed using SVIX to manage errors, retries, and security.
If the registered endpoint goes into a timeout or does not answer with an HTTP code different from 2xx, then the system will automatically retry the delivery according to the following schema:
after 5 seconds;
after 5 minutes;
after 30 minutes;
after 2 hours;
after 5 hours;
after 10 hours;
after 10 hours.
Besides the API call, you can create a callback using the Web Trust dashboard.

Use the CREATE button.

After at least one callback is created, you can check your SVIX dashboard by clicking INSPECT CALLBACKS.

By clicking on the different endpoint, you can see the details of the callback delivery over time.

Was this helpful?
More in Getting started
What is Web Trust?The company behind Web Trust?First stepsMaking the first API CallStill need help? Share an idea