Compare commits

..

2 Commits

Author SHA1 Message Date
Lucas Smith 925ff7fdfa chore: add disclaimer to related rejection files 2026-06-22 16:24:34 +10:00
Lucas Smith e887356949 feat: add API endpoint to reject documents on behalf of recipients
Programmatically record an external rejection on behalf of a recipient
who declined outside the platform. Flags the rejection as external in
the audit log, optionally attributes it to a specific team member via
actAsEmail, and enforces team membership and document visibility.
2026-06-22 14:27:18 +10:00
8 changed files with 623 additions and 6 deletions
@@ -0,0 +1,260 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const rejectRecipient = (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipientId: number,
reason: string,
actAsEmail?: string,
) => {
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
recipientId,
reason,
actAsEmail,
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
});
};
test.describe('Reject recipient on behalf of', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-reject-recipient',
expiresIn: null,
}));
});
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
const auditData = auditLog!.data as Record<string, unknown>;
expect(auditData.recipientId).toBe(recipient.id);
expect(auditData.recipientEmail).toBe(recipient.email);
expect(auditData.reason).toBe('Declined out of band');
expect(auditData.isExternal).toBe(true);
// No actAsEmail supplied - the rejection defaults to the API user.
expect(auditLog!.userId).toBe(user.id);
expect(auditLog!.email).toBe(user.email);
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
});
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
const member = await seedTeamMember({ teamId: team.id });
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
// The audit log actor must be the elected member, not the API user.
expect(auditLog.userId).toBe(member.id);
expect(auditLog.email).toBe(member.email);
const auditData = auditLog.data as Record<string, unknown>;
expect(auditData.isExternal).toBe(true);
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
});
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
// A user that exists but belongs to a different team.
const { user: outsider } = await seedUser();
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
token,
envelope.id,
recipient.id,
'Declined out of band',
outsider.email,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Reject once - succeeds.
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
expect(firstRes.ok()).toBeTruthy();
// Reject again - the recipient is no longer NOT_SIGNED.
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
// The original rejection reason must remain unchanged.
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.rejectionReason).toBe('First rejection');
});
test('should not allow rejecting a recipient in another team', async ({ request }) => {
// Seed a separate team/user that owns the document.
const { user: otherUser, team: otherTeam } = await seedUser();
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Use the original team's token - it must not be able to reject.
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should return 404 for a non-existent recipient', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
const recipient = targetEnvelope.recipients[0];
// Valid recipient ID, but paired with the wrong envelope ID.
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
const { team: visTeam, owner } = await seedTeam();
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
const { token: managerToken } = await createApiToken({
userId: manager.id,
teamId: visTeam.id,
tokenName: 'manager-reject-token',
expiresIn: null,
});
// ADMIN-visibility document owned by the team owner.
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
managerToken,
envelope.id,
recipient.id,
'Should be hidden by visibility',
);
// Visibility failure surfaces as not-found, matching the canonical checks.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
});
@@ -0,0 +1,215 @@
// This is closely related to `reject-document-with-token.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from an API/programmatic perspective (an authenticated API user acting
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
// from a recipient perspective (the recipient rejecting via their token).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type RejectDocumentOnBehalfOfOptions = {
/**
* The ID of the envelope the recipient belongs to. Required so the caller
* targets an explicit envelope/recipient combination rather than resolving the
* envelope implicitly from the recipient ID.
*/
envelopeId: string;
recipientId: number;
userId: number;
teamId: number;
reason: string;
/**
* The email of a team member to attribute the rejection to. Must be a member
* of the team. When omitted the rejection is attributed to the API user that
* owns the token (`userId`).
*
* This exists so external applications can elect which team member is acting
* on behalf of the recipient, rather than always defaulting to the API user.
*/
actAsEmail?: string;
requestMetadata: ApiRequestMetadata;
};
/**
* Reject a document on behalf of a recipient as an authenticated API user.
*
* This is used to programmatically record a rejection for cases where the
* recipient declined to sign outside of the platform (e.g. before ever
* reaching it). The rejection is flagged as `isExternal` in the audit log to
* distinguish it from a rejection performed by the recipient directly.
*
* The action can optionally be attributed to a specific team member via
* `actAsEmail`; otherwise it is attributed to the API user.
*/
export async function rejectDocumentOnBehalfOf({
envelopeId,
recipientId,
userId,
teamId,
reason,
actAsEmail,
requestMetadata,
}: RejectDocumentOnBehalfOfOptions) {
// Build the access-controlled envelope query. This enforces team membership
// AND document visibility (and owner / team-email access), mirroring the
// canonical envelope access checks used across the app.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId,
teamId,
});
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
include: {
envelope: true,
},
});
const envelope = recipient?.envelope;
if (!recipient || !envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document or recipient not found',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} must be pending to reject`,
});
}
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} has already actioned this document`,
});
}
assertRecipientNotExpired(recipient);
// Resolve the user the rejection should be attributed to. When `actAsEmail`
// is supplied it must resolve to a member of the team; otherwise the rejection
// is attributed to the API user that owns the token.
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
// Update the recipient status to rejected and record an external rejection
// audit log within the same transaction.
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signedAt: new Date(),
signingStatus: SigningStatus.REJECTED,
rejectionReason: reason,
},
}),
prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
// Always attribute the audit log to a concrete user: the elected team
// member when supplied, otherwise the API user that owns the token.
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
metadata: requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
reason,
isExternal: true,
// Only set when a member was explicitly elected via `actAsEmail`.
onBehalfOfUserEmail: electedUser?.email,
onBehalfOfUserName: electedUser?.name,
},
}),
}),
]);
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Trigger the seal document job to process the document asynchronously.
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId: legacyDocumentId,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Send email notifications to the rejecting recipient.
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
recipientId: recipient.id,
documentId: legacyDocumentId,
},
});
// Send cancellation emails to other recipients.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
return updatedRecipient;
}
/**
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
* user). Throws when the email does not resolve to a member of the team.
*/
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
if (!actAsEmail) {
return null;
}
const electedUser = await prisma.user.findFirst({
where: {
email: actAsEmail,
},
});
if (!electedUser) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
const isTeamMember = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
});
if (!isTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
return electedUser;
};
@@ -1,3 +1,11 @@
// This is closely related to `reject-document-on-behalf-of.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from a recipient perspective (the recipient rejecting via their token),
// whereas `reject-document-on-behalf-of.ts` focuses on it from an API/programmatic
// perspective (an authenticated API user acting on behalf of a recipient).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
+13 -1
View File
@@ -560,12 +560,24 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
});
/**
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
* Event: Document recipient rejected the document.
*/
export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED),
data: ZBaseRecipientDataSchema.extend({
reason: z.string(),
/**
* Whether the rejection was recorded externally on behalf of the recipient
* via the API, rather than by the recipient directly on the platform.
*/
isExternal: z.boolean().optional(),
/**
* The team member the external rejection was recorded on behalf of, when
* the API caller elected a specific member to attribute the action to.
* Absent when the rejection is attributed to the API user/token itself.
*/
onBehalfOfUserEmail: z.string().optional(),
onBehalfOfUserName: z.string().nullable().optional(),
}),
});
+25 -5
View File
@@ -509,11 +509,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
user: msg`${user} completed their task`,
}));
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
anonymous: msg`Recipient rejected the document`,
you: msg`You rejected the document`,
user: msg`${user} rejected the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
if (data.isExternal) {
const onBehalfOf = data.onBehalfOfUserName || data.onBehalfOfUserEmail;
if (onBehalfOf) {
return {
anonymous: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
you: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
user: msg`The document was rejected externally by ${onBehalfOf} on behalf of ${user}`,
};
}
return {
anonymous: msg`Recipient rejected the document externally`,
you: msg`The document was rejected externally on behalf of the recipient`,
user: msg`The document was rejected externally on behalf of ${user}`,
};
}
return {
anonymous: msg`Recipient rejected the document`,
you: msg`You rejected the document`,
user: msg`${user} rejected the document`,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
anonymous: msg`Recipient requested a 2FA token for the document`,
you: msg`You requested a 2FA token for the document`,
@@ -0,0 +1,65 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { rejectDocumentOnBehalfOf } from '@documenso/lib/server-only/document/reject-document-on-behalf-of';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../../trpc';
import {
rejectEnvelopeRecipientOnBehalfOfMeta,
ZRejectEnvelopeRecipientOnBehalfOfRequestSchema,
ZRejectEnvelopeRecipientOnBehalfOfResponseSchema,
} from './reject-envelope-recipient-on-behalf-of.types';
export const rejectEnvelopeRecipientOnBehalfOfRoute = authenticatedProcedure
.meta(rejectEnvelopeRecipientOnBehalfOfMeta)
.input(ZRejectEnvelopeRecipientOnBehalfOfRequestSchema)
.output(ZRejectEnvelopeRecipientOnBehalfOfResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId, recipientId, reason, actAsEmail } = input;
ctx.logger.info({
input: {
envelopeId,
recipientId,
},
});
// This is an external-only action: it must only be reachable through the
// public API, never the internal app TRPC handler.
if (ctx.metadata.source !== 'apiV2') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This route is only accessible via the public API',
});
}
await rejectDocumentOnBehalfOf({
envelopeId,
recipientId,
userId: user.id,
teamId,
reason,
actAsEmail,
requestMetadata: ctx.metadata,
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId,
});
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
include: {
fields: true,
},
});
return recipient;
});
@@ -0,0 +1,35 @@
import { ZEnvelopeRecipientSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
export const rejectEnvelopeRecipientOnBehalfOfMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/recipient/{recipientId}/reject',
summary: 'Reject envelope recipient on behalf of',
description:
'Records a rejection on behalf of a recipient. Use this when a recipient has declined to ' +
'sign outside of the platform. The rejection is flagged as external in the document audit ' +
'log. By default the action is attributed to the API user; supply `actAsEmail` to attribute ' +
'it to a specific team member.',
tags: ['Envelope Recipients'],
},
};
export const ZRejectEnvelopeRecipientOnBehalfOfRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope the recipient belongs to.'),
recipientId: z.number().describe('The ID of the recipient to reject the document on behalf of.'),
reason: z.string().min(1).describe('The reason the recipient rejected the document.'),
actAsEmail: zEmail()
.optional()
.describe('The email of the team member to attribute the rejection to. Defaults to the API user when omitted.'),
});
export const ZRejectEnvelopeRecipientOnBehalfOfResponseSchema = ZEnvelopeRecipientSchema;
export type TRejectEnvelopeRecipientOnBehalfOfRequest = z.infer<typeof ZRejectEnvelopeRecipientOnBehalfOfRequestSchema>;
export type TRejectEnvelopeRecipientOnBehalfOfResponse = z.infer<
typeof ZRejectEnvelopeRecipientOnBehalfOfResponseSchema
>;
@@ -22,6 +22,7 @@ import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fie
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
import { rejectEnvelopeRecipientOnBehalfOfRoute } from './envelope-recipients/reject-envelope-recipient-on-behalf-of';
import { reportRecipientRoute } from './envelope-recipients/report-recipient';
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
@@ -70,6 +71,7 @@ export const envelopeRouter = router({
delete: deleteEnvelopeRecipientRoute,
set: setEnvelopeRecipientsRoute,
report: reportRecipientRoute,
rejectOnBehalfOf: rejectEnvelopeRecipientOnBehalfOfRoute,
},
field: {
get: getEnvelopeFieldRoute,