Configure Webhooks
Integrate asynchronous capture notifications
Webhook SetupAt this time, webhooks are managed by the Truepic team. Please reach out if we still need to set one up for you!
The Lens API uses webhooks to notify you when it has created, updated, processed, or deleted a capture. Each org unit can have one or more webhooks configured, and each one can be scoped to the exact types you desire, allowing you to receive the results everywhere you need.
Receive a Processed Capture
To receive a webhook request from Lens, we need to add a POST route to handle it:
/* Routes */
app.post('/webhook', (req, res) => {
console.log(`Processing webhook: ${req.body.type}`)
console.dir(req.body.data, { depth: null })
res.sendStatus(200)
})It's up to you how to proceed with the data. Here we're just logging it to the console, but you could create or update records in your database, download the file(s) to store in your infrastructure and kick off subsequent processes that depend on the data – or any combination of those. Again, it depends on your use case. Our only recommendation is to run those tasks asynchronously as background jobs if they take more than a few seconds. Lens will close the connection if it doesn't receive a response in a reasonable amount of time.
Finally, we reply with a 200 status to signal that the webhook has been received successfully. No response body is necessary. If an error does occur, a 4xx or 5xx status will cause Lens to retry the request later (up to 5 times).
Verify a Webhook Request (Optional)
We're now receiving and processing the webhook, but there's a potential security hole: a completely open route that doesn't require any authentication. A bad actor with enough willpower could craft a request to trick the webhook route into trusting data not from Lens. To prevent this scenario from being successful, each request includes a truepic-signature header that can be verified to ensure it's actually from Lens. While this is optional, it's not difficult to implement, and we recommend it for increased security.
The truepic-signature header looks like this:
truepic-signature: t=1634066973,s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU
As you can see, there's a t value that's the timestamp of when the request was sent, and an s value that's the signature of the request.
Let's walk through how to parse and verify these.
Parse the Header Value
To start, split the header value on the comma (,). This should leave you with
two parts:
t=1634066973
s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU
Next, split each part on the equals (=). This should leave you with two values
for each part:
t
1634066973
s
6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU
Here's our service that does that with some validation along the way:
/* Services */
function parseSignatureHeader(header) {
const error = new Error('Invalid truepic-signature header')
if (!header?.length) {
throw error
}
const [timestampParts, signatureParts] = header.split(',')
if (!timestampParts?.length || !signatureParts?.length) {
throw error
}
let [t, timestamp] = timestampParts.split('=')
if (t !== 't' || !timestamp?.length) {
throw error
}
timestamp = Number(timestamp)
if (isNaN(timestamp)) {
throw error
}
const [s, signature] = signatureParts.split('=')
if (s !== 's' || !signature?.length) {
throw error
}
return { timestamp, signature }
}Verify the Timestamp
The timestamp (t) can be verified to ensure it's a recent request and not a potentially delayed replay attack. Some leeway should be allowed in case the clocks on either end of the request aren't quite in sync.
Here's our service with 5 minutes of leeway:
/* Services */
function verifyTimestamp({ timestamp, leewayMinutes = 5 }) {
const diff = Math.abs(Date.now() - timestamp * 1000)
const diffMinutes = Math.ceil(diff / (1000 * 60))
return leewayMinutes >= diffMinutes
}Verify the Signature
The signature (s) can be verified to ensure the recipient and payload haven't been tampered with. This is done by rebuilding the signature – an HMAC digest using SHA-256 that's Base64-encoded – with a secret that only Lens and you are privy to. (The Truepic team shared this secret with you when we set up your webhook earlier.)
To create the message to sign, join your webhook's URL together with the timestamp (t) and the raw request body using a comma (,). It's important to use the raw request body (a string) before it's parsed as JSON, as different languages/frameworks can parse/stringify JSON in subtly different ways, which could result in different signatures.
Next, sign the {{url}},{{timestamp}},{{body}} message with the secret and compare the Base64-encoded signatures with a constant-time algorithm to prevent timing attacks.
Here's how that looks in our service that relies on Node.js's crypto module for all of the heavy lifting:
/* Dependencies */
import { createHmac, timingSafeEqual } from 'crypto'
/* Services */
function verifySignature({ url, timestamp, body, secret, signature }) {
const comparisonSignature = createHmac('sha256', secret)
comparisonSignature.update([url, timestamp, body].join(','))
return timingSafeEqual(
Buffer.from(comparisonSignature.digest('base64'), 'base64'),
Buffer.from(signature, 'base64')
)
}Putting It All Together
Now that we have services to parse and verify the truepic-signature header, let's update our webhook route to put it all together:
/* Constants */
const WEBHOOK_URL = `http://localhost:${PORT}/webhook`
const WEBHOOK_SECRET = 'secret'
/* Routes */
app.post(
'/webhook',
express.raw({
type: 'application/json',
}),
(req, res, next) => {
const { timestamp, signature } = parseSignatureHeader(
req.header('truepic-signature')
)
const isTimestampVerified = verifyTimestamp({ timestamp })
if (!isTimestampVerified) {
console.warn('Invalid timestamp')
return res.sendStatus(200)
}
const isSignatureVerified = verifySignature({
url: WEBHOOK_URL,
timestamp,
body: req.body.toString(),
secret: WEBHOOK_SECRET,
signature,
})
if (!isSignatureVerified) {
console.warn('Invalid signature')
return res.sendStatus(200)
}
next()
},
(req, res) => {
const body = JSON.parse(req.body.toString())
console.log(`Processing webhook: ${body.type}`)
console.dir(body, { depth: null })
res.sendStatus(200)
}
)Delete a Processed Capture (Optional)
The last piece of code we will add is a service that deletes the processed capture and all associated files from Lens. This part is optional, as we auto-delete captures 30 days after they're processed (or any configurable time you desire). But it's good hygiene to keep potentially sensitive data on a third-party system like Lens as long as necessary, so we offer an API to delete a capture as soon as you're ready.
Here's how that service looks:
/* Services */
async function deleteFromLens(id) {
const response = await fetch(`https://lens-api.truepic.com/captures/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${API_KEY}`,
},
})
return response.ok
}Where this should be called from depends on how you process the file. If you have everything you need by the end of the webhook request, you could slot it in just before replying with the 200 status:
// ...
await deleteFromLens(body.data.id)
res.sendStatus(200)However, if you end up with a more complex processing pipeline consisting of asynchronous background jobs, waiting until the final step of that sequence makes more sense. The general principle is: delete once you've stored all of the data and files on your end. That's when it's safe to remove it from Lens.
Reference
Request
Lens makes a POST request to each of your configured webhook URLs with the following payload structure:
APPLICATION/JSON
Name | Type | Value |
|---|---|---|
type | string | The unique identifier of the webhook type. Must be one of: |
data | object | The payload specific to the webhook type. |
Response
Your server only needs to respond with a 2xx for Lens to consider it successfully delivered. No response body is necessary. A 4xx or 5xx error response will cause Lens to retry the request later (up to 5 times).
Types
For all types, data returns the captures object unless otherwise noted.
Capture notified
captures.notified
At this time, only uploads directly from the Web SDK trigger this type.
{
"type": "captures.notified",
"data": {
"created_at": "2022-01-28T17:47:10.950Z",
"custom_data": {
"session_id": 123
},
"file_hash": "cLBztEy/HJmLSPnn/BMdz2bisUJOVV4bbztQQiVy/rU=",
"file_size": 6815727,
"id": "8511d369-7646-4799-ac63-9f0503a06412",
"processed_at": null,
"status": "RECEIVING",
"type": "PHOTO",
"updated_at": "2022-01-28T17:47:10.950Z",
"uploaded_by_ip_address": "127.0.0.1"
}
}Capture created
captures.created
{
"type": "captures.created",
"data": {
"created_at": "2022-01-28T17:47:10.950Z",
"custom_data": {
"session_id": 123
},
"file_hash": "cLBztEy/HJmLSPnn/BMdz2bisUJOVV4bbztQQiVy/rU=",
"file_size": 6815727,
"id": "8511d369-7646-4799-ac63-9f0503a06412",
"processed_at": null,
"status": "WAITING",
"type": "PHOTO",
"updated_at": "2022-01-28T17:47:10.950Z",
"uploaded_by_ip_address": "127.0.0.1",
"url": "https://s3.us-east-2.amazonaws.com/lens-captures-staging/8511d369-7646-4799-ac63-9f0503a06412.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIASOSZDTMLO4XDS6NZ%2F20220128%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20220128T174711Z&X-Amz-Expires=36000&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEK%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMiJIMEYCIQDMe6oh4zcrQ0084ueO2ybvmXCEj4bo5ss4c2VlyIsrwwIhAIUFG3yz758aW5RhygNlgoj9URYS8NU%2F7x2v%2BdhwUph0KoMECNj%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQAhoMMTY4NzY0MzQ5MjA2IgyZJVVfpMUXYsrsLNkq1wMxXV8M0SGmgj1QKMQ8ZhgI465PIvdsxl7mIGIFKFwvbWEfteME95s6w302LiIWWQt7kCs1QaNMNso4xAFEdWfz3Pz07n%2FO0MMUsqznsYLUe8eWzKw9e%2BR7QHrT5b%2BoECtF%2FF5cLAX4DdCd2UtpULtPQRL9wjEY2jMLfu8MeuWbUBNFICOhYwLjy8mVxM%2BunpBc0Vtz0DkoUS39R%2FLPPARMhDIxvkOtAMDsiAxSdSGXzfsSVFYQ0rtrJYmhY0MW3iXfjWbeDUEFziKo%2FF7TAPy8breTR5LXamf5lS04mhZ9jOGMOPkbfij9ZFzKP0ISj3NSmunEmWKxc2nEevvssfTuitYTk4WgtplNmIBtddyHBU3kq001YgcrJjt8uoT5RylN8BEVsABH55sM2oFN%2FjtC%2BGq6eadGdJL6kRSckH4%2BoqWqGhBO3d6s1VKmjJ2XxALhJRmKJhRwMiM%2FczlfKmQY4wdpfNYs9FSim6kAum5whTAG6rgHEGkFp1NhozJGgXoWdArJFR63yyzL9HLlrOu7pP5xAdi8VNl4pfTC%2FES5f9nEuri4NAfdbk7uTDljMdkXzJrEKfRBNPviK9gFJL%2BZ4MfXCZA%2B6qWQfclyaE9n6Pd8cSKntvkw9YLQjwY6pAHrfGSnAiNwAxShL7BBXkustbuMQcJYOvlZTCbmXs1PkLWzdhr3Os9tDz%2BED9cQrmWwj0D%2BQ2iFXqw9PH7UIF2vuUf9XYZDk9LrpLe%2B3JmUc5aOiLUAoR1YAU0PM%2ByXxyGBwO%2BmWnw0jJYx3MwBginL0vL7c8phC2G62JSZmpfeP7LvHzl73Z6MZEBqZi4%2FAKLB9LWRf6f1fws9STIZCfrjjbKP6w%3D%3D&X-Amz-Signature=f91af826a8bd0887c6bc7dfb4222cefa9f83deb6dec70af0eb0926da51c139b5&X-Amz-SignedHeaders=host&x-id=GetObject"
}
}Capture updated
captures.updated
{
"type": "captures.updated",
"data": {
"id": "8511d369-7646-4799-ac63-9f0503a06412",
"...": "..."
}
}Capture processed
captures.processed
{
"type": "captures.processed",
"data": {
"id": "8511d369-7646-4799-ac63-9f0503a06412",
"...": "..."
}
}An error includes an error object:
{
"type": "captures.processed",
"data": {
"error": {
"message": "File must be of type `image/jpeg`",
"status": 422
},
"id": "8511d369-7646-4799-ac63-9f0503a06412",
"...": "..."
}
}Capture deleted
captures.deleted
{
"type": "captures.deleted",
"data": {
"id": "8511d369-7646-4799-ac63-9f0503a06412",
"...": "..."
}
}Updated about 1 year ago
