Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/webhooks/src/controller/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class AppController {
try {
// There is no request validation for the Fax API, so we can parse it and revive its content directly
const event = faxCallbackWebhook.parseEvent(request.body);
// Once the request has been revived, delegate the event management to the SMS service
// Once the request has been revived, delegate the event management to the Fax service
const contentType = request.headers['content-type'];
this.faxEventService.handleEvent(event, contentType, file);
res.status(200).send();
Expand Down
4 changes: 4 additions & 0 deletions examples/webhooks/src/services/fax-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export class FaxEventService {
console.log(`** multipart/form-data\n${event.event}: ${event.fax!.id} - ${event.eventTime}`);
console.log('Saving file...');
const filePath = path.join('./fax-upload', file!.originalname);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, file!.buffer);
console.log('File saved! ' + filePath);
}
Expand All @@ -29,6 +31,8 @@ export class FaxEventService {
private saveBase64File(event: Fax.IncomingFaxEventJson | Fax.FaxCompletedEventJson, faxId: string) {
console.log('Saving file...');
const filePath = path.join('./fax-upload', event.event + '-' + faxId + '.' + event.fileType!.toLowerCase());
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const buffer = Buffer.from(event.file!, 'base64');
fs.writeFileSync(filePath, buffer);
console.log('File saved! ' + filePath);
Expand Down
57 changes: 50 additions & 7 deletions packages/fax/src/rest/v3/callbacks/callbacks-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ export class FaxCallbackWebhooks implements CallbackProcessor<FaxWebhookEventPar
* Reviver for a Fax Event
* This method ensures the object can be treated as a Fax Event and should be called before any action is taken to manipulate the object.
* @param {any} eventBody - the event body or form containing the Fax event notification.
* Accepted formats:
* - A JSON string (application/json content type)
* - A multipart/form-data raw body string (fields will be extracted automatically)
* - An already-parsed object
* @return {FaxWebhookEventParsed} - The parsed Fax event object
*/
public parseEvent(eventBody: any): FaxWebhookEventParsed {
if (typeof eventBody === 'string') {
eventBody = JSON.parse(eventBody);
if (eventBody.trimStart().startsWith('--')) {
eventBody = this.parseMultipartFormData(eventBody);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, @asein-sinch!

Should we trim eventBody before passing it to parseMultipartFormData()? The ^ regex inside the method will fail to match if the body has leading whitespace, even though the trimStart() check in the condition already handles it. Passing the trimmed string keeps the two in sync:

  • eventBody = this.parseMultipartFormData(eventBody.trimStart());

What do you think?

} else {
eventBody = JSON.parse(eventBody);
}
}
let incomingFaxEvent: IncomingFaxEvent | null = null;
let faxCompletedEvent: FaxCompletedEvent | null = null;
Expand All @@ -23,8 +31,7 @@ export class FaxCallbackWebhooks implements CallbackProcessor<FaxWebhookEventPar
if (eventBody.eventTime) {
incomingFaxEvent.eventTime = new Date(eventBody.eventTime);
}
// In case of multipart/form-data, the server may not have parsed the 'fax' property as a JSON object, so we do it here
if (typeof eventBody.fax === 'string') {
if (eventBody.fax) {
incomingFaxEvent.fax = this.reviveFax(eventBody.fax);
}
return incomingFaxEvent;
Expand All @@ -33,8 +40,7 @@ export class FaxCallbackWebhooks implements CallbackProcessor<FaxWebhookEventPar
if (faxCompletedEvent.eventTime) {
faxCompletedEvent.eventTime = new Date(faxCompletedEvent.eventTime);
}
// In case of multipart/form-data, the server may not have parsed the 'fax' property as a JSON object, so we do it here
if (typeof eventBody.fax === 'string') {
if (eventBody.fax) {
faxCompletedEvent.fax = this.reviveFax(eventBody.fax);
}
return faxCompletedEvent;
Expand All @@ -46,8 +52,45 @@ export class FaxCallbackWebhooks implements CallbackProcessor<FaxWebhookEventPar
throw new Error('Unknown Fax event');
}

private reviveFax(faxAsString: string): Fax {
const fax: Fax = JSON.parse(faxAsString);
/**
* Parses a raw multipart/form-data body string into a key-value object.
* The boundary is detected from the first line of the body.
* @param {string} body - The raw multipart/form-data body string.
* @return {Record<string, string>} - An object with field names as keys and field values as strings.
*/
private parseMultipartFormData(body: string): Record<string, string> {
const result: Record<string, string> = {};
// The first line is the boundary delimiter (e.g. "--<boundary>")
const boundaryMatch = body.match(/^(--[^\r\n]+)/);
if (!boundaryMatch) {
throw new Error('Unable to detect multipart boundary from the body');
}
const boundary = boundaryMatch[1];
// Split the body by the boundary; the first part is empty and the last ends with "--"
const parts = body.split(boundary).filter((part) => part && part.trim() !== '--' && part.trim() !== '');
for (const part of parts) {
const nameMatch = part.match(/Content-Disposition:\s*form-data;\s*name="([^"]+)"/i);
if (!nameMatch) {continue;}
const fieldName = nameMatch[1];
// The value comes after the first blank line (header/body separator)
const headerBodySeparator = part.indexOf('\r\n\r\n') !== -1 ? '\r\n\r\n' : '\n\n';
const separatorIndex = part.indexOf(headerBodySeparator);
if (separatorIndex === -1) {continue;}
let value = part.substring(separatorIndex + headerBodySeparator.length);
// Remove trailing boundary markers: a closing boundary (--boundary--) may remain
// if its dash count differs slightly from the opening boundary
const trailingBoundaryIndex = value.search(/\r?\n--[-]+[^\r\n]*--\s*$/);
if (trailingBoundaryIndex !== -1) {
value = value.substring(0, trailingBoundaryIndex);
}
value = value.replace(/\r?\n$/, ''); // Trim trailing newline
result[fieldName] = value;
}
return result;
}

private reviveFax(faxInput: string | Fax): Fax {
const fax: Fax = typeof faxInput === 'string' ? JSON.parse(faxInput) : faxInput;
if (fax.createTime) {
fax.createTime = new Date(fax.createTime);
}
Expand Down
193 changes: 193 additions & 0 deletions packages/fax/tests/rest/v3/webhooks/callbacks-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { Fax, FaxCallbackWebhooks } from '../../../../src';

describe('Fax Callback Webhook', () => {
let callbackWebhooks: FaxCallbackWebhooks;

const EVENT_TIME_AS_STRING = '2026-04-09T21:33:46Z';
const EVENT_TIME_AS_DATE = new Date(EVENT_TIME_AS_STRING);
const CREATE_TIME_AS_STRING = '2026-04-08T14:23:48Z';
const CREATE_TIME_AS_DATE = new Date(CREATE_TIME_AS_STRING);
const COMPLETED_TIME_AS_STRING = '2026-04-08T14:25:00Z';
const COMPLETED_TIME_AS_DATE = new Date(COMPLETED_TIME_AS_STRING);

const FAX_OBJECT = {
id: '01KNPQW6SGZKAZEX8ZX7MCN559',
direction: 'INBOUND',
from: '+16179216545',
to: '+17818510006',
numberOfPages: 1,
status: 'COMPLETED',
createTime: CREATE_TIME_AS_STRING,
completedTime: COMPLETED_TIME_AS_STRING,
projectId: '37b62a7b-0177-429a-bb0b-e10f848de0b8',
serviceId: '01HQK89SYWBCJK1DMYD3QAW2DW',
};

beforeEach(() => {
callbackWebhooks = new FaxCallbackWebhooks();
});

it('should parse an INCOMING_FAX event from an object payload', () => {
const payload = {
event: 'INCOMING_FAX',
eventTime: EVENT_TIME_AS_STRING,
fax: { ...FAX_OBJECT },
};
const result = callbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent;
expect(result.event).toBe('INCOMING_FAX');
expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE);
expect(result.fax).toBeDefined();
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE);
});

it('should parse a FAX_COMPLETED event from an object payload', () => {
const payload = {
event: 'FAX_COMPLETED',
eventTime: EVENT_TIME_AS_STRING,
fax: { ...FAX_OBJECT },
};
const result = callbackWebhooks.parseEvent(payload) as Fax.FaxCompletedEvent;
expect(result.event).toBe('FAX_COMPLETED');
expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE);
expect(result.fax).toBeDefined();
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE);
});

it('should parse an INCOMING_FAX event from a JSON string', () => {
const payload = JSON.stringify({
event: 'INCOMING_FAX',
eventTime: EVENT_TIME_AS_STRING,
fax: { ...FAX_OBJECT },
});
const result = callbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent;
expect(result.event).toBe('INCOMING_FAX');
expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE);
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE);
});

it('should parse a FAX_COMPLETED event from a JSON string', () => {
const payload = JSON.stringify({
event: 'FAX_COMPLETED',
eventTime: EVENT_TIME_AS_STRING,
fax: { ...FAX_OBJECT },
});
const result = callbackWebhooks.parseEvent(payload) as Fax.FaxCompletedEvent;
expect(result.event).toBe('FAX_COMPLETED');
expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE);
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE);
});

it('should parse an INCOMING_FAX event from a multipart/form-data body with CRLF', () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, @asein-sinch!
We have the CRLF tests for the multipart parsing. Is there any value to add equivalent ones for LF-only line endings (.join('\n'))?
Best!

const boundary = '----Boundary123';
const faxJson = JSON.stringify(FAX_OBJECT);
const multipartBody = [
`--${boundary}`,
'Content-Disposition: form-data; name="event"',
'',
'INCOMING_FAX',
`--${boundary}`,
'Content-Disposition: form-data; name="eventTime"',
'',
EVENT_TIME_AS_STRING,
`--${boundary}`,
'Content-Disposition: form-data; name="fax"',
'Content-Type: application/json',
'',
faxJson,
`--${boundary}--`,
].join('\r\n');

const result = callbackWebhooks.parseEvent(multipartBody) as Fax.IncomingFaxEvent;
expect(result.event).toBe('INCOMING_FAX');
expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE);
expect(result.fax).toBeDefined();
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE);
expect(result.fax!.completedTime).toStrictEqual(COMPLETED_TIME_AS_DATE);
});

it('should parse a FAX_COMPLETED event from a multipart/form-data body with CRLF', () => {
const boundary = '----Boundary123';
const faxJson = JSON.stringify(FAX_OBJECT);
const multipartBody = [
`--${boundary}`,
'Content-Disposition: form-data; name="event"',
'',
'FAX_COMPLETED',
`--${boundary}`,
'Content-Disposition: form-data; name="eventTime"',
'',
EVENT_TIME_AS_STRING,
`--${boundary}`,
'Content-Disposition: form-data; name="fax"',
'Content-Type: application/json',
'',
faxJson,
`--${boundary}--`,
].join('\r\n');

const result = callbackWebhooks.parseEvent(multipartBody) as Fax.FaxCompletedEvent;
expect(result.event).toBe('FAX_COMPLETED');
expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE);
expect(result.fax).toBeDefined();
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE);
expect(result.fax!.completedTime).toStrictEqual(COMPLETED_TIME_AS_DATE);
});

it('should parse an event without eventTime', () => {
const payload = {
event: 'INCOMING_FAX',
fax: { ...FAX_OBJECT },
};
const result = callbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent;
expect(result.event).toBe('INCOMING_FAX');
expect(result.eventTime).toBeUndefined();
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
});

it('should parse an event using the static method', () => {
const payload = {
event: 'INCOMING_FAX',
eventTime: EVENT_TIME_AS_STRING,
fax: { ...FAX_OBJECT },
};
const result = FaxCallbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent;
expect(result.event).toBe('INCOMING_FAX');
expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE);
expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559');
});

it('should always return true for authentication validation', () => {
const result = callbackWebhooks.validateAuthenticationHeader({}, '', '/fax', 'POST');
expect(result).toBeTruthy();
});

it('should throw an error when the event type is unknown', () => {
const payload = {
event: 'UNKNOWN_EVENT',
eventTime: EVENT_TIME_AS_STRING,
};
expect(() => callbackWebhooks.parseEvent(payload)).toThrow('Unknown Fax event: UNKNOWN_EVENT');
});

it('should throw an error when the event property is missing', () => {
const payload = {
eventTime: EVENT_TIME_AS_STRING,
};
expect(() => callbackWebhooks.parseEvent(payload)).toThrow('Unknown Fax event');
});

it('should throw an error when parsing an invalid JSON string', () => {
expect(() => callbackWebhooks.parseEvent('not valid json')).toThrow();
});

it('should throw an error when parsing a multipart body with no detectable boundary', () => {
// A string starting with "--" but with no actual content
expect(() => callbackWebhooks.parseEvent('--\r\n')).toThrow('Unable to detect multipart boundary from the body');
});
});
71 changes: 71 additions & 0 deletions packages/fax/tests/rest/v3/webhooks/webhooks-events.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Given, Then, When } from '@cucumber/cucumber';
import { FaxCallbackWebhooks, Fax } from '../../../../src';
import assert from 'assert';

let faxCallbackWebhook: FaxCallbackWebhooks;
let rawEvent: string;
let event: Fax.FaxWebhookEventParsed;

Given('the Fax Webhooks handler is available', function () {
faxCallbackWebhook = new FaxCallbackWebhooks();
});

When('I send a request to trigger the INCOMING_FAX event with the "{}" content type', async (contentType) => {
const response = await fetch(`http://localhost:3012/webhooks/fax/incoming-fax/${contentType}`);
rawEvent = await response.text();
event = faxCallbackWebhook.parseEvent(rawEvent);
});

Then('the event describes an INCOMING_FAX event with the application-json content type', () => {
assert.equal(event.event, 'INCOMING_FAX');
assert.deepEqual(event.eventTime, new Date('2024-06-06T14:42:35.000Z'));
const fax = event.fax!;
assert.equal(fax.id, '01W4FFL35P4NC4K35CR3P35TOP1');
assert.equal(fax.direction, 'INBOUND');
assert.equal(fax.from, '+16179216545');
assert.equal(fax.to, '+17818510000');
assert.equal(fax.numberOfPages, 1);
assert.equal(fax.status, 'COMPLETED');
assert.equal(fax.headerTimeZone, 'UTC');
assert.equal(fax.retryDelaySeconds, 60);
assert.equal(fax.resolution, 'FINE');
assert.equal(fax.callbackUrl, 'https://my.callback.url/fax');
assert.equal(fax.callbackUrlContentType, 'application/json');
assert.equal(fax.projectId, '123coffee-dada-beef-cafe-baadc0de5678');
assert.equal(fax.serviceId, '01W4FFL35P4NC4K35FAXSERVICE');
assert.equal(fax.price?.amount, '0.045');
assert.equal(fax.price?.currencyCode, 'USD');
assert.equal(fax.maxRetries, 0);
assert.deepEqual(fax.createTime, new Date('2026-06-06T14:42:22Z'));
assert.deepEqual(fax.completedTime, new Date('2026-06-06T14:42:22Z'));
assert.equal(fax.headerText, '');
assert.equal(fax.headerPageNumbers, true);
assert.equal(fax.hasFile, true);
});

Then('the event describes an INCOMING_FAX event with the multipart-formdata content type', () => {
assert.equal(event.event, 'INCOMING_FAX');
assert.deepEqual(event.eventTime, new Date('2024-06-06T14:42:46.000Z'));
const fax = event.fax!;
assert.equal(fax.id, '01W4FFL35P4NC4K35CR3P35TOP2');
assert.equal(fax.from, '+16179216545');
assert.equal(fax.to, '+17818510000');
assert.equal(fax.numberOfPages, 0);
assert.equal(fax.status, 'FAILURE');
assert.equal(fax.headerTimeZone, 'UTC');
assert.equal(fax.retryDelaySeconds, 60);
assert.equal(fax.resolution, 'FINE');
assert.equal(fax.callbackUrl, 'https://my.callback.url/fax');
assert.equal(fax.callbackUrlContentType, 'multipart/form-data');
assert.equal(fax.errorType, 'FAX_ERROR');
assert.equal(fax.errorMessage, 'No fax tone detected');
assert.equal(fax.errorCode, 132);
assert.equal(fax.projectId, '123coffee-dada-beef-cafe-baadc0de5678');
assert.equal(fax.serviceId, '01W4FFL35P4NC4K35FAXSERVICE');
assert.equal(fax.maxRetries, 0);
assert.deepEqual(fax.createTime, new Date('2026-06-06T14:42:48Z'));
assert.deepEqual(fax.completedTime, new Date('2026-06-06T14:42:48Z'));
assert.equal(fax.headerText, '');
assert.equal(fax.headerPageNumbers, true);
assert.equal(fax.hasFile, false);
});
Loading