Skip to main content

Webhooks Guide

Webhooks are an advanced feature that allow you to subscribe to Coral events via HTTP.

You can configure webhooks on your installation of Coral by visiting /admin/configure/webhooks.

Once you've configured a webhook endpoint in Coral, you will receive updates from Coral when those events occur. These will be in the form of POST requests with a JSON payload consisting of the schema represented below.

Webhook Signing

Each webhook sent by Coral is signed by your webhook endpoint signing secret. The signature method closely resembles the signing method used by Stripe for their v1 signing method. The X-Coral-Signature header contains one or more signatures prefixed by sha256=.

If you receive a signature containing multiple signatures, it is typically when you have rolled the signing secret from the administrative panel, and chosen to keep the previous secret active for a duration of time.

How to verify the signature(s)

// Set your signing secret here from the administration panel.
const SIGNING_SECRET = "< YOUR SIGNING SECRET HERE >";

// We're using crypto to verify the signatures.
const crypto = require("crypto");

// We're using express to receive webhooks here.
const app = require("express")();

// Use the body-parser to get the raw body as a buffer so we can use it with the
// hashing functions.
const parser = require("body-parser");

function extractEvent(body, sig) {
// Step 1: Extract signatures from the header.
const signatures = sig
// Split the header by `,` to get a list of elements.
.split(",")
// Split each element by `=` to get a prefix and value pair.
.map((element) => element.split("="))
// Grab all the elements with the prefix of `sha256`.
.filter(([prefix]) => prefix === "sha256")
// Grab the value from the prefix and value pair.
.map(([, value]) => value);

// Step 2: Prepare the `signed_payload`.
const signed_payload = body;

// Step 3: Calculate the expected signature.
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(signed_payload)
.digest()
.toString("hex");

// Step 4: Compare signatures.
if (
// For each of the signatures on the request...
!signatures.some((signature) =>
// Compare the expected signature to the signature on in the header. If at
// least one of the match, we should continue to process the event.
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
)
) {
throw new Error("Invalid signature");
}

// Parse the JSON for the event.
return JSON.parse(body.toString());
}

app.post("/webhook", parser.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-coral-signature"];

let event;

try {
// Parse the JSON for the event.
event = extractEvent(req.body, sig);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}

// Handle the event.
switch (event.type) {
case "STORY_CREATED":
const data = event.data;
console.log(
`A Story with ID ${data.storyID} and URL ${data.storyURL} was created!`
);
break;
case "COMMENT_CREATED":
// ... handle COMMENT_CREATED event
break;
case "COMMENT_REPLY_CREATED":
// ... handle COMMENT_REPLY_CREATED event
break;
// ... handle other event types.
default:
// Unexpected event type
return response.status(400).end();
}

// Return a response to acknowledge receipt of the event
res.json({ received: true });
});

app.listen(4242, () => console.log("Running on port 4242"));

The procedure of how to verify the signatures follows.

Step 1: Extract signatures from the header

Split the header using , as the separator, to get a list of elements. Then split each of these elements using = as the separator, to get a prefix and value pair. The value for the prefix sha256 corresponds to the signature(s).

Step 2: Prepare the signed_payload string

You can do this by taking the string contents of the body (before parsing or the request body).

Step 3: Calculate the expected signature

Compute an HMAC signature using the SHA256 hash function. You can use the webhook endpoint's signing secret as the key, and the above calculated signed_payload as the message.

Step 4: Compare signatures

Compare the signature(s) in the header to the expected signature. To protect against timing attacks, ensure you use a constant-time string comparison function when comparing signatures.

Schema

{
/**
* id is the identifier for this event, each event
* will have a unique id.
*/
id: string;

/**
* type is the name of this event, this indicates
* what is stored in the following `data` property.
* Refer to the `Events List` below to see what the
* type is for each event.
*/
type: string;

/**
* data is the object representing this particular
* event. Each type of event has a different shape
* to the data property. Refer to the `Events List`
* below to see what the data looks like for each
* event.
*/
data: object;

/**
* createdAt is the ISO 8601 representation of the
* date when this event was created.
*/
createdAt: string;

/**
* tenantID is the ID of the Tenant that this event originated at.
*/
tenantID: string;

/**
* tenantDomain is the domain that is associated with this Tenant that this event originated at.
*/
tenantDomain: string;
}

Events Listing

Events

{
id: string;
type: "STORY_CREATED";
tenantID: string;
tenantDomain: string;
data: {
/**
* storyID is the ID of the newly created Story.
*/
storyID: string;

/**
* storyURL is the URL of the newly created Story.
*/
storyURL: string;

/**
* siteID is the Site that the newly created Story was created on.
*/
siteID: string;
}
createdAt: string;
}
  • COMMENT_CREATED
    • only sent for published comments (ie status NONE or APPROVED)
{
id: string;
type: "COMMENT_CREATED";
tenantID: string;
tenantDomain: string;
data: {
/**
* storyID is the ID of the story on which the comment was created.
*/
storyID: string;

/**
* siteID is the ID of the site to which the story belongs.
*/
siteID: string;

/**
* commentID is the ID of the newly created comment.
*/
commentID: string;
}
createdAt: string;
}
{
id: string;
type: "COMMENT_REPLY_CREATED";
tenantID: string;
tenantDomain: string;
data: {
/**
* commentID is the ID of the reply comment.
*/
commentID: string;

/**
* storyID is the ID of the story on which the reply comment was made.
*/
storyID: string;

/**
* siteID is the ID of the site to which the story belongs.
*/
siteID: string;

/**
* ancestorIDs are the IDs of the ancestoral comments like parent, grandparent, etc
*/
ancestorIDs: string[];
}
createdAt: string;
}
{
id: string;
type: "COMMENT_ENTERED_MODERATION_QUEUE";
tenantID: string;
tenantDomain: string;
data: {
/**
* queue is the moderation queue that the comment has been placed into (eg PENDING).
*/
queue: string;

/**
* commentID is the ID of the comment.
*/
commentID: string;

/**
* status is the state that the comment now has (eg PREMOD).
*/
status: string;

/**
* storyID is the ID of the story on which the comment was made.
*/
storyID: string;

/**
* siteID is the ID of the site to which the story belongs.
*/
siteID: string;
}
createdAt: string;
}
{
id: string;
type: "COMMENT_LEFT_MODERATION_QUEUE";
tenantID: string;
tenantDomain: string;
data: {
/**
* queue is the moderation queue that the comment has exited (eg PENDING).
*/
queue: string;

/**
* commentID is the ID of the comment.
*/
commentID: string;

/**
* status is the state that the comment now has (eg REJECTED).
*/
status: string;

/**
* storyID is the ID of the story on which the comment was made.
*/
storyID: string;

/**
* siteID is the ID of the site to which the story belongs.
*/
siteID: string;
}
createdAt: string;
}