> ## Documentation Index
> Fetch the complete documentation index at: https://developer.box.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Automate offer letter creation with Box Doc Gen and Box Sign

> Build an HR workflow that generates offer letters with Box Doc Gen, routes them through Box Sign for HR approval, and delivers them to candidates for signature.

export const RelatedLinks = ({title, items = []}) => {
  const getBadgeClass = badge => {
    if (!badge) return "badge-default";
    const badgeType = badge.toLowerCase().replace(/\s+/g, "-");
    return `badge-${badge === "ガイド" ? "guide" : badgeType}`;
  };
  if (!items || items.length === 0) {
    return null;
  }
  return <div className="my-8">
      {}
      <h3 className="text-sm font-bold uppercase tracking-wider mb-4">{title}</h3>

      {}
      <div className="flex flex-col gap-3">
        {items.map((item, index) => <a key={index} href={item.href} className="py-2 px-3 rounded related_link hover:bg-[#f2f2f2] dark:hover:bg-[#111827] flex items-center gap-3 group no-underline hover:no-underline border-b-0">
            {}
            <span className={`px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide flex-shrink-0 ${getBadgeClass(item.badge)}`}>
              {item.badge}
            </span>

            {}
            <span className="text-base">{item.label}</span>
          </a>)}
      </div>
    </div>;
};

export const Link = ({href, children, className, ...props}) => {
  const localizedHref = localizeLink(href);
  return <a href={localizedHref} className={className} {...props}>
      {children}
    </a>;
};

In modern document workflows, two of the most challenging parts are generating consistently formatted documents and managing e-signature execution at scale. This tutorial demonstrates how to address both challenges by integrating the Box Doc Gen API and the Box Sign API into a single, automated pipeline.

In this scenario, you build an automated pipeline that submits candidate data and gets a signed PDF back with no manual steps in between. Once you pass the candidate and offer details, the solution calls the Box Doc Gen API to merge that data into an offer letter template. When the PDF is ready, a Box webhook notifies the app which PDF file has been generated. This initiates a Box Sign request for that generated offer letter, which routes it through HR approval and candidate signature.

The code is minimal by design. The goal is to show the application pattern clearly so you
can extend it into a larger platform workflow, swap in your own templates, or add more
signers and approval steps. When your HR system sends candidate and offer details, the service:

1. Calls Box Doc Gen to merge the data into an offer letter template.
2. Listens for a Doc Gen completion webhook and records the generated PDF.
3. Creates a Box Sign request so an HR approver reviews the letter before the candidate signs.

<Card title="Clone the working sample" href="https://github.com/box-community/offer-letter-workflow" icon="github" arrow="true">
  Prefer to start from running code? The complete app built in this tutorial is on GitHub. Clone it, add your Box credentials, and run.
</Card>

## What you are building

By the end of this tutorial, you have a working Node.js service that:

* Accepts offer data from an applicant tracking system (ATS) or HR webhook.
* Validates the payload and calls Box Doc Gen to merge data into an offer letter template.
* Listens for a Doc Gen completion webhook and records the generated PDF file ID.
* Creates a Box Sign request with an HR approver and the candidate as signers.
* Exposes endpoints to trigger generation, send for signature, and check offer letter status.

<Note>
  This sample is designed to be triggered by an ATS such as Greenhouse, but the same pattern applies to Workday Recruiting, SAP SuccessFactors, or any HR system that can send a webhook and expose offer data through an API.
</Note>

## Prerequisites

Before you start, make sure you have the following:

* Node.js 18 or higher.
* A Box Enterprise Advanced account with <Link href="/guides/docgen/docgen-getting-started">Box Doc Gen</Link> entitlement and <Link href="/guides/box-sign">Box Sign</Link> enabled in the Admin Console. Box Doc Gen is not available on free developer accounts.
* A Box application configured with **Client Credentials Grant** authentication, authorized in the Admin Console. See <Link href="/guides/authentication/client-credentials">Client Credentials Grant</Link> for setup details.
* The Box enterprise ID for your account (`BOX_ENTERPRISE_ID`). The Client Credentials Grant app authenticates as the enterprise service account. Find it in the Developer Console under your app's **General Settings**, or in the Admin Console.

## Step-by-step process

This solution uses three Box Platform capabilities:

| Component       | Purpose                                                   | API                                                            |
| --------------- | --------------------------------------------------------- | -------------------------------------------------------------- |
| **Box Doc Gen** | Merge candidate and offer data into a PDF from a template | `POST /2.0/docgen_batches`                                     |
| **Webhooks**    | Detect when document generation completes                 | Doc Gen webhook event (`DOCGEN_DOCUMENT_GENERATION_SUCCEEDED`) |
| **Box Sign**    | Route the PDF through HR approval and candidate signature | `POST /2.0/sign_requests`                                      |

<Steps>
  <Step title="Enable Box Doc Gen and Box Sign">
    Go to the Admin Console and navigate to the Enterprise Settings tab. Enable Box Doc Gen in the **Content & Sharing** tab and Box Sign in the **Box Sign** tab for selected users or all enterprise users.
  </Step>

  <Step title="Create Box folders">
    Create three folders in Box:

    1. `Offer Letter Template`
    2. `Generated Offer Letters`
    3. `Signed Offer Letters`

    Copy each folder ID from the Box web app URL (for example, `https://app.box.com/folder/123456789` → `123456789`). You need these values for the project variables `BOX_DOCGEN_DESTINATION_FOLDER_ID` and `BOX_SIGN_PARENT_FOLDER_ID`.

    Collaborate the app's service account into the template, generated-offer, and signed-offer folders with **Editor** access. Service accounts do not automatically have access to user-owned content. You can find the service account address (`AutomationUser_...@boxdevedition.com`) in the Developer Console under your app's **General Settings**.
  </Step>

  <Step title="Create an offer letter template">
    Use a preexisting Box Doc Gen template in the Box web app:

    1. Open the **Automate** tab in Box.
    2. Navigate to the **Doc Gen** tab.
    3. Select the **Employee Offer Letter** template.
    4. Select a destination folder for the template and generated documents.
    5. Select **Generate Document**, then save the **template file ID** from the page or from the URL.

    To match the sample template, the ATS payload must include the fields the `validateOffer` function checks in a later step: `id`, `country`, `contractDate`, `deliveryTerms`, `companyName`, `baseSalary`, `salaryDetails`, `salaryModel`, `employee.address`, `employee.dob`, `employee.email`, `employee.name`, `company.address`, `company.designatedSigner`, `company.name`, `company.designation`, `company.department`, `position`, `department`, and `startDate`.
  </Step>

  <Step title="Create and configure your Box application">
    Create a **Custom App** with **Server Authentication (Client Credentials Grant)** in the <Link href="https://app.box.com/developers/console">Box Developer Console</Link>.

    1. Enable the **Read all files and folders**, **Write all files and folders**, **Manage Doc Gen**, **Manage signature requests**, and **Manage webhooks** scopes.
    2. Save your changes and authorize the app in the Developer Console in **App Details** section under **Status**.
  </Step>

  <Step title="Set up the development environment">
    Build the project from scratch so you understand what each file does. The rest of this tutorial walks through every file.

    1. Open your terminal and create a new project directory:

    ```bash theme={null}
    mkdir offer-letter-workflow && cd offer-letter-workflow
    ```

    2. Initialize `package.json` and enable ES modules. The service uses `import`/`export` syntax, so Node needs `"type": "module"` to run the files:

    ```bash theme={null}
    npm init -y
    npm pkg set type=module
    ```

    3. Install the required packages:

    ```bash theme={null}
    npm install box-node-sdk@^10 dotenv express
    ```

    4. Create a `.env` file to store your credentials, then add the following content. Replace the placeholder values with your credentials and folder IDs from the [Box Developer Console](https://app.box.com/developers/console):

    ```bash theme={null}
    PORT=8000
    APP_SHARED_SECRET=replace-me
    BOX_CLIENT_ID=your_client_id
    BOX_CLIENT_SECRET=your_client_secret
    BOX_ENTERPRISE_ID=your_enterprise_id
    BOX_DOCGEN_TEMPLATE_FILE_ID=your_template_file_id
    BOX_DOCGEN_DESTINATION_FOLDER_ID=your_generated_docs_folder_id
    BOX_SIGN_PARENT_FOLDER_ID=your_signed_docs_folder_id
    HR_APPROVER_EMAIL=hr.approver@example.com
    PUBLIC_BASE_URL=https://your-tunnel.example.com
    ```

    For local development, expose your server with a tunnel (for example, ngrok) and set `PUBLIC_BASE_URL` to the tunnel URL. In production, verify webhook signatures. See <Link href="/guides/webhooks/v2/signatures-v2">verify webhook signatures</Link>.
  </Step>

  <Step title="Create configuration and storage helpers">
    In your project, create a `config.js` file and add the following code. It loads environment variables used throughout the service:

    ```javascript theme={null}
    import "dotenv/config";
    const required = [
      "BOX_CLIENT_ID",
      "BOX_CLIENT_SECRET",
      "BOX_ENTERPRISE_ID",
      "BOX_DOCGEN_TEMPLATE_FILE_ID",
      "BOX_DOCGEN_DESTINATION_FOLDER_ID",
      "BOX_SIGN_PARENT_FOLDER_ID",
    ];

    export function readConfig() {
      const missing = required.filter((name) => !process.env[name]);
      if (missing.length > 0) {
        throw new Error(`Set required environment variables: ${missing.join(", ")}`);
      }

      return {
        port: Number(process.env.PORT || 8000),
        sharedSecret: process.env.APP_SHARED_SECRET,
        boxClientId: process.env.BOX_CLIENT_ID,
        boxClientSecret: process.env.BOX_CLIENT_SECRET,
        boxEnterpriseId: process.env.BOX_ENTERPRISE_ID,
        docgenTemplateFileId: process.env.BOX_DOCGEN_TEMPLATE_FILE_ID,
        docgenDestinationFolderId: process.env.BOX_DOCGEN_DESTINATION_FOLDER_ID,
        signParentFolderId: process.env.BOX_SIGN_PARENT_FOLDER_ID,
        hrApproverEmail: process.env.HR_APPROVER_EMAIL,
        publicBaseUrl: process.env.PUBLIC_BASE_URL,
      };
    }
    ```

    Create `store.js` with a simple in-memory store for the demo. Replace this with a database before you deploy to production.

    ```javascript theme={null}
    const offers = new Map();

    export function createOfferRecord(offer, docgenBatch) {
      const id = offer.id || docgenBatch.id;
      const record = {
        id,
        offer,
        docgenBatchId: docgenBatch.id,
        generatedFileId: null,
        signRequestId: null,
        status: "docgen_started",
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };

      offers.set(id, record);
      return record;
    }

    export function findOffer(id) {
      return offers.get(id);
    }

    export function findOfferByBatchId(batchId) {
      return [...offers.values()].find((offer) => offer.docgenBatchId === batchId);
    }

    export function findOfferByGeneratedFileName(fileName) {
      return [...offers.values()].find((record) => {
        const expectedName = `${record.offer.employee.name} - Offer Letter`;
        return fileName === expectedName || fileName === `${expectedName}.pdf`;
      });
    }

    export function listOffers() {
      return [...offers.values()];
    }

    export function updateOffer(id, changes) {
      const record = findOffer(id);
      if (!record) return null;

      const next = {
        ...record,
        ...changes,
        updatedAt: new Date().toISOString(),
      };
      offers.set(id, next);
      return next;
    }
    ```

    Create `box-client.js` and add a function that creates a Box client authenticated with Client Credentials Grant:

    ```javascript theme={null}
    import { BoxClient, BoxCcgAuth, CcgConfig } from "box-node-sdk";

    export function createBoxClient(config) {
      const ccgConfig = new CcgConfig({
        clientId: config.boxClientId,
        clientSecret: config.boxClientSecret,
        enterpriseId: config.boxEnterpriseId,
      });

      return new BoxClient({
        auth: new BoxCcgAuth({ config: ccgConfig }),
      });
    }
    ```

    <Note>
      The app calls Box APIs with the permissions of the service account.
    </Note>
  </Step>

  <Step title="Validate offer data">
    Create `offer-letter-data.js` with functions that validate incoming offer payloads before you call Doc Gen. The `buildOfferLetterData` function is a pass-through hook you can extend to map or transform fields before Doc Gen merges them into the template.

    ```javascript theme={null}
    export function buildOfferLetterData(offer) {
      return offer;
    }

    export function validateOffer(offer) {
      const required = [
        "id",
        "country",
        "contractDate",
        "deliveryTerms",
        "companyName",
        "baseSalary",
        "salaryDetails",
        "salaryModel",
        "employee.address",
        "employee.dob",
        "employee.email",
        "employee.name",
        "company.address",
        "company.designatedSigner",
        "company.name",
        "company.designation",
        "company.department",
        "position",
        "department",
        "startDate",
      ];

      const missing = required.filter((field) => !readPath(offer, field));
      if (missing.length > 0) {
        throw new Error(`Missing offer fields: ${missing.join(", ")}`);
      }

      if (!Array.isArray(offer.salaryDetails) || offer.salaryDetails.length === 0) {
        throw new Error("salaryDetails must be a non-empty array.");
      }

      if (!offer.employee.email.includes("@")) {
        throw new Error("employee.email must be a valid email address.");
      }
    }

    function readPath(value, path) {
      return path.split(".").reduce((current, key) => current?.[key], value);
    }
    ```
  </Step>

  <Step title="Generate offer letters with Box Doc Gen">
    Create `box-docgen.js` with functions that start document generation and parse webhook payloads:

    ```javascript theme={null}
    import { buildOfferLetterData } from "./offer-letter-data.js";

    export async function generateOfferLetter(client, config, offer) {
      return client.docgen.createDocgenBatchV2025R0({
        file: {
          id: config.docgenTemplateFileId,
          type: "file",
        },
        inputSource: "api",
        destinationFolder: {
          id: config.docgenDestinationFolderId,
          type: "folder",
        },
        outputType: "pdf",
        documentGenerationData: [
          {
            generatedFileName: `${offer.employee.name} - Offer Letter`,
            userInput: buildOfferLetterData(offer),
          },
        ],
      });
    }

    export function readDocgenWebhookInfo(event) {
      const info = [event.additional_info, event.additionalInfo].flat()[0] || {};
      const source = event.source || {};

      return {
        batchId: info.batch_id || info.batchId,
        generatedFileId: info.generated_file_id || info.generatedFileId || source.id,
        generatedFileName: source.name,
      };
    }
    ```

    See <Link href="/guides/docgen/generate-document">generate documents</Link> for additional API options.
  </Step>

  <Step title="Send offer letters for signature with Box Sign">
    Create `box-sign.js` with functions that collaborate the HR approver on the generated file and create a Box Sign request. In this example, the HR team member acts as an approver and reviews the documents, so they can double-check the offer details. Once this is approved in Box, the candidate receives the offer letter in their mailbox, with a custom email message valid for 14 days:

    ```javascript theme={null}
    function isAlreadyCollaboratedError(error) {
      return error.responseInfo?.statusCode === 409;
    }

    async function collaborateApproverOnFile(client, fileId, approverEmail) {
      try {
        return await client.userCollaborations.createCollaboration(
          {
            item: {
              id: fileId,
              type: "file",
            },
            accessibleBy: {
              type: "user",
              login: approverEmail,
            },
            role: "viewer",
            isAccessOnly: true,
          },
          {
            queryParams: {
              notify: false,
            },
          },
        );
      } catch (error) {
        if (isAlreadyCollaboratedError(error)) return null;
        throw error;
      }
    }

    export async function sendOfferLetterForSignature(client, config, offerRecord) {
      const signers = [];

      if (config.hrApproverEmail) {
        await collaborateApproverOnFile(
          client,
          offerRecord.generatedFileId,
          config.hrApproverEmail,
        );

        signers.push({
          email: config.hrApproverEmail,
          role: "approver",
          order: 1,
        });
      }

      signers.push({
        email: offerRecord.offer.employee.email,
        role: "signer",
        order: config.hrApproverEmail ? 2 : 1,
      });

      return client.signRequests.createSignRequest({
        sourceFiles: [
          {
            id: offerRecord.generatedFileId,
            type: "file",
          },
        ],
        parentFolder: {
          id: config.signParentFolderId,
          type: "folder",
        },
        signers,
        name: `${offerRecord.offer.employee.name} - Offer Letter`,
        emailSubject: `Offer letter for ${offerRecord.offer.position}`,
        emailMessage: "Please review and sign your offer letter.",
        areRemindersEnabled: true,
        daysValid: 14,
        externalId: offerRecord.id,
      });
    }

    export async function getSignRequest(client, signRequestId) {
      return client.signRequests.getSignRequestById(signRequestId);
    }
    ```

    Box Sign also supports phone verification, password protection, and other advanced security options. See <Link href="/guides/box-sign/create-sign-request">create Box Sign request</Link> for the full parameter list.
  </Step>

  <Step title="Build the Express server">
    Create `server.js` to orchestrate the workflow. The server loads the Box configuration from environment variables, and creates a Box SDK client. It then exposes the REST endpoints:

    ```javascript theme={null}
    import "dotenv/config";
    import express from "express";
    import { createBoxClient } from "./box-client.js";
    import { generateOfferLetter, readDocgenWebhookInfo } from "./box-docgen.js";
    import { getSignRequest, sendOfferLetterForSignature } from "./box-sign.js";
    import { readConfig } from "./config.js";
    import { validateOffer } from "./offer-letter-data.js";
    import {
      createOfferRecord,
      findOffer,
      findOfferByBatchId,
      findOfferByGeneratedFileName,
      listOffers,
      updateOffer,
    } from "./store.js";

    const config = readConfig();
    const client = createBoxClient(config);
    const app = express();

    app.use(express.json());
    // Define a function to require a shared secret for the /offer-letters endpoint.
    // This is a simple way to protect the endpoint from unauthorized access.
    // In production, use a more secure authentication method.
    function requireSharedSecret(request, response, next) {
      if (!config.sharedSecret) return next();
      if (request.headers["x-shared-secret"] === config.sharedSecret) return next();
      return response.status(401).json({ error: "Invalid shared secret." });
    }
    // The server.js file includes the following routes.
    app.get("/", (_request, response) => {
      response.json({
        name: "Box Offer Letter Demo",
        routes: [
          "POST /offer-letters",
          "GET /offer-letters",
          "POST /box/webhooks",
          "POST /offer-letters/:id/send-for-signature",
          "GET /offer-letters/:id/sign-request",
        ],
      });
    });
    // POST /offer-letters accepts offer data, validates it, calls Box Doc Gen to generate
    // the offer letter PDF, and stores an in-memory record with the Doc Gen batch ID.
    app.post("/offer-letters", requireSharedSecret, async (request, response, next) => {
      try {
        const offer = request.body.offer || request.body;
        validateOffer(offer);

        const batch = await generateOfferLetter(client, config, offer);
        const record = createOfferRecord(offer, batch);

        response.status(202).json({
          offerId: record.id,
          docgenBatchId: record.docgenBatchId,
          status: record.status,
        });
      } catch (error) {
        next(error);
      }
    });
    // GET /offer-letters lists all in-memory offer records and their statuses.
    app.get("/offer-letters", (_request, response) => {
      response.json({ entries: listOffers() });
    });
    // POST /box/webhooks handles the Doc Gen completion webhook. It reads the batch ID,
    // generated file ID, and generated file name from the webhook payload, and updates
    // the in-memory offer record with the generated file ID and status.
    async function handleDocgenWebhook(request, response, next) {
      try {
        const { batchId, generatedFileId, generatedFileName } = readDocgenWebhookInfo(
          request.body,
        );

        if (!generatedFileId) {
          return response
            .status(202)
            .json({ handled: false, reason: "No generated file ID found." });
        }

        const record = batchId
          ? findOfferByBatchId(batchId)
          : findOfferByGeneratedFileName(generatedFileName);

        if (!record) {
          return response
            .status(202)
            .json({ handled: false, reason: "Unknown generated offer letter." });
        }

        const updated = updateOffer(record.id, {
          generatedFileId,
          status: "ready_for_signature",
        });

        return response.json({ handled: true, offer: updated });
      } catch (error) {
        return next(error);
      }
    }

    app.post("/box/webhooks", handleDocgenWebhook);
    // POST /offer-letters/:id/send-for-signature creates a Box Sign request for the offer
    // letter. It first checks whether the offer letter PDF is ready, and then calls the
    // sendOfferLetterForSignature function to create the Box Sign request.
    app.post("/offer-letters/:id/send-for-signature", requireSharedSecret, async (request, response, next) => {
      try {
        const record = findOffer(request.params.id);
        if (!record) return response.status(404).json({ error: "Offer not found." });
        if (!record.generatedFileId) {
          return response.status(409).json({
            error: "Offer letter PDF is not ready yet. Wait for the Doc Gen webhook.",
          });
        }

        const signRequest = await sendOfferLetterForSignature(client, config, record);
        const updated = updateOffer(record.id, {
          signRequestId: signRequest.id,
          status: "signature_sent",
        });

        return response.status(201).json({
          offer: updated,
          signRequest: {
            id: signRequest.id,
            status: signRequest.status,
            prepareUrl: signRequest.prepareUrl,
          },
        });
      } catch (error) {
        return next(error);
      }
    });
    // GET /offer-letters/:id/sign-request returns the latest Box Sign status for an offer.
    app.get("/offer-letters/:id/sign-request", async (request, response, next) => {
      try {
        const record = findOffer(request.params.id);
        if (!record) return response.status(404).json({ error: "Offer not found." });
        if (!record.signRequestId) {
          return response
            .status(409)
            .json({ error: "No Box Sign request for this offer yet." });
        }

        const signRequest = await getSignRequest(client, record.signRequestId);

        return response.json({
          id: signRequest.id,
          status: signRequest.status,
          signers: signRequest.signers,
          signFiles: signRequest.signFiles,
        });
      } catch (error) {
        return next(error);
      }
    });
    // Finally, start the app.
    app.use((error, _request, response, _next) => {
      response.status(error.statusCode || 500).json({
        error: error.message,
      });
    });

    app.listen(config.port, () => {
      console.log(`Box Offer Letter Demo running at http://localhost:${config.port}`);
      if (config.publicBaseUrl) {
        console.log(`Webhook URL: ${config.publicBaseUrl}/box/webhooks`);
      }
    });
    ```

    <Note>
      Protect key routes with the shared secret in production, and verify webhook signatures before you process Doc Gen events. The in-memory `store.js` module is for demos only — replace it with a database for production before you deploy.
    </Note>

    At this point, your project directory should contain the following files:

    ```
    offer-letter-workflow/
    ├── .env
    ├── box-client.js
    ├── box-docgen.js
    ├── box-sign.js
    ├── config.js
    ├── offer-letter-data.js
    ├── package.json
    ├── server.js
    └── store.js
    ```
  </Step>

  <Step title="Configure the Doc Gen webhook">
    Register the webhook on the **template file**, not the destination folder. The `DOCGEN_DOCUMENT_GENERATION_SUCCEEDED` trigger is file-only; registering it on a folder returns a `400 invalid_parameter` error. For more options, see <Link href="/guides/docgen/mark-template">mark file as Box Doc Gen template</Link>.

    Configure a V2 Box webhook for your app:

    1. Define the URL where webhook payloads are sent.
    2. Select the Box Doc Gen template document as the trigger.
    3. Enable the **Document Generation Succeeded** trigger (`DOCGEN_DOCUMENT_GENERATION_SUCCEEDED`).

    See <Link href="/guides/webhooks/v2/create-v2">add webhooks</Link> for the full walkthrough.

    Or register the webhook with the Box CLI. Replace `$BOX_ACCESS_TOKEN` with a valid access token from your CCG-authenticated client:

    ```bash theme={null}
    box request /webhooks \
      -X POST \
      --token "$BOX_ACCESS_TOKEN" \
      --body '{
        "target": {
          "type": "file",
          "id": "BOX_DOCGEN_TEMPLATE_FILE_ID"
        },
        "address": "https://APP_URL/box/webhooks",
        "triggers": ["DOCGEN_DOCUMENT_GENERATION_SUCCEEDED"]
      }'
    ```
  </Step>

  <Step title="Test the application">
    Start the server:

    ```bash theme={null}
    node server.js
    ```

    Confirm the server is running:

    ```bash theme={null}
    curl http://localhost:8000
    ```

    Send a test offer payload. Update the shared secret and port to match your `.env` file, then run:

    ```bash theme={null}
    curl -X POST http://localhost:8000/offer-letters \
      -H "content-type: application/json" \
      -H "x-shared-secret: replace-me" \
      -d '{
        "country": "US",
        "contractDate": "18-08-2025",
        "deliveryTerms": "30 days",
        "companyName": "Xyz Inc",
        "baseSalary": 280000,
        "employee": {
          "address": "Sample Street, Sample City, SA-456",
          "dob": "12-12-1993",
          "email": "max.exampleman@example.com",
          "name": "Max Exampleman"
        },
        "salaryDetails": [
          {
            "component": "Base Salary",
            "value": 280000
          },
          {
            "component": "Payment terms",
            "value": "monthly"
          },
          {
            "component": "Stock options",
            "value": "1300 options (vesting over 4 years)"
          }
        ],
        "salaryModel": "per year",
        "company": {
          "address": "Example Street, Example City, EX-456",
          "designatedSigner": "John Doe",
          "name": "Acme Inc",
          "designation": "VP",
          "department": "Procurement"
        },
        "id": "12305",
        "position": "Manager",
        "department": "Procurement",
        "startDate": "18-08-2025"
      }'
    ```

    Example response:

    ```json theme={null}
    {
      "offerId": "12305",
      "docgenBatchId": "123456",
      "status": "docgen_started"
    }
    ```

    Open the **Generated Offer Letters** folder in Box. After Doc Gen finishes, the PDF appears there and Box sends the webhook to your app.

    **Simulate the webhook locally** if you do not have a public URL:

    ```bash theme={null}
    curl -X POST http://localhost:8000/box/webhooks \
      -H "content-type: application/json" \
      -d '{
        "trigger": "DOCGEN_DOCUMENT_GENERATION_SUCCEEDED",
        "additional_info": {
          "batch_id": "DOCGEN_BATCH_ID_FROM_PREVIOUS_STEP",
          "generated_file_id": "GENERATED_PDF_FILE_ID"
        }
      }'
    ```

    List in-memory offers:

    ```bash theme={null}
    curl http://localhost:8000/offer-letters
    ```

    Send the offer for signature:

    ```bash theme={null}
    curl -X POST http://localhost:8000/offer-letters/12305/send-for-signature \
      -H "x-shared-secret: replace-me"
    ```

    Check Box Sign status:

    ```bash theme={null}
    curl http://localhost:8000/offer-letters/12305/sign-request
    ```

    Common Box Sign statuses include `created`, `sent`, `viewed`, `signed`, `declined`, and `expired`.

    **Check the result:**

    * `GET /offer-letters` lists all in-memory offer records and their statuses.
    * `GET /offer-letters/:id/sign-request` returns the latest Box Sign status for an offer.
    * The HR approver receives the document first. After approval, the candidate receives the offer letter by email.
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Unauthorized or invalid access token">
    Confirm `BOX_CLIENT_ID`, `BOX_CLIENT_SECRET`, and `BOX_ENTERPRISE_ID` are correct in your `.env` file. Verify the app uses Client Credentials Grant and is authorized in the Admin Console. See <Link href="/guides/authentication/client-credentials">Client Credentials Grant</Link>.
  </Accordion>

  <Accordion title="404 Not Found when generating documents">
    The service account does not have access to the template file or destination folder. Confirm the service account is collaborated into the template and folders with Editor access, and that the template file ID and folder IDs are correct.
  </Accordion>

  <Accordion title="Offer letter PDF is not ready yet (409)">
    The Doc Gen webhook has not arrived yet. Confirm your webhook is registered on the template file with the `DOCGEN_DOCUMENT_GENERATION_SUCCEEDED` trigger, and that your server is reachable at the configured URL.
  </Accordion>

  <Accordion title="Missing offer fields validation error">
    The offer payload is missing required fields for the Doc Gen template. Compare your payload against the `required` array in `validateOffer` and make sure nested fields such as `employee.email` are present.
  </Accordion>

  <Accordion title="Doc Gen or Box Sign authorization errors">
    Confirm the app uses Client Credentials Grant, the app has read/write, Doc Gen, and Sign scopes, and the service account can access the template file, write to the generated-offers folder, and write to the signed-offers folder. Confirm Doc Gen and Box Sign are enabled for the account in the Admin Console.
  </Accordion>

  <Accordion title="The candidate does not receive a signing email">
    Check `employee.email` in the offer payload. This value is used as the Box Sign recipient.
  </Accordion>

  <Accordion title="Missing required environment variables">
    Confirm `.env` includes `BOX_CLIENT_ID`, `BOX_CLIENT_SECRET`, `BOX_ENTERPRISE_ID`, `BOX_DOCGEN_TEMPLATE_FILE_ID`, `BOX_DOCGEN_DESTINATION_FOLDER_ID`, and `BOX_SIGN_PARENT_FOLDER_ID`.
  </Accordion>
</AccordionGroup>

## Other use cases

The same Box Doc Gen and Box Sign pattern applies to other workflows that need both document generation and approval or signature:

| Team            | Example workflow                                                                                                          |
| --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **Sales**       | Generate quotes, order forms, or sales contracts, then route them for customer signature                                  |
| **Legal**       | Create NDAs, MSAs, or engagement letters, then send them through legal approval and counterparty signature                |
| **HR**          | Prepare promotion letters, stock agreements, or employee contracts, then collect internal approval and employee signature |
| **Procurement** | Generate vendor agreements, RFP responses, or work orders, then route them for supplier signature                         |
| **Finance**     | Generate investor reports, QBR packets, or tax documents, then route them for review and secure distribution              |

## Scaling to production

<AccordionGroup>
  <Accordion title="Replace in-memory storage">
    The demo `store.js` module loses data on restart. Persist offer records in a database so you can correlate Doc Gen batch IDs, generated file IDs, and Box Sign request IDs across restarts and retries.
  </Accordion>

  <Accordion title="Verify webhook signatures">
    In production, validate that Doc Gen webhook payloads originate from Box before you update offer records. See <Link href="/guides/webhooks/v2/signatures-v2">verify webhook signatures</Link>.
  </Accordion>

  <Accordion title="Reauthorize after configuration changes">
    If you change scopes or the access level on the CCG app, an administrator must reauthorize the app in the Admin Console under **Integrations > Platform Apps** before API calls succeed again.
  </Accordion>

  <Accordion title="Customize Box Sign workflow">
    Add multiple approvers, in-person signing, password protection, or phone verification by extending the `signers` array and other Box Sign request parameters. See <Link href="/guides/box-sign/create-sign-request">create Box Sign request</Link>.
  </Accordion>

  <Accordion title="Harden webhook and API handling">
    Make webhook handling idempotent because delivery can produce duplicates. Add retries for Box API calls, protect routes with stronger auth than a shared secret, and avoid logging access tokens, employee personal data, compensation details, or signed-document links.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Generate documents with Box Doc Gen" href={localizeLink("/guides/docgen/generate-document")} icon="file-lines" arrow="true">
    Learn how to generate documents with Box Doc Gen.
  </Card>

  <Card title="Create Box Sign request" href={localizeLink("/guides/box-sign/create-sign-request")} icon="signature" arrow="true">
    Explore advanced Sign request options, including multi-signer flows and security settings.
  </Card>
</CardGroup>

<RelatedLinks
  title="RELATED GUIDES"
  items={[
{ label: translate("Get started with Box Doc Gen"), href: "/guides/docgen/docgen-getting-started", badge: "GUIDE" },
{ label: translate("Create Box Sign request"), href: "/guides/box-sign/create-sign-request", badge: "GUIDE" },
{ label: translate("Verify webhook signatures"), href: "/guides/webhooks/v2/signatures-v2", badge: "GUIDE" },
{ label: translate("Client Credentials Grant"), href: "/guides/authentication/client-credentials", badge: "GUIDE" },
{ label: translate("Select an authentication method"), href: "/guides/authentication/select", badge: "GUIDE" }
]}
/>
