Sign Up

Sign Up to our social questions and Answers Engine to ask questions, answer people’s questions, and connect with other people.

Have an account? Sign In

Have an account? Sign In Now

Sign In

Login to our social questions & Answers Engine to ask questions answer people’s questions & connect with other people.

Sign Up Here

Forgot Password?

Don't have account, Sign Up Here

Forgot Password

Lost your password? Please enter your email address. You will receive a link and will create a new password via email.

Have an account? Sign In Now

You must login to ask a question.

Forgot Password?

Need An Account, Sign Up Here

Please briefly explain why you feel this question should be reported.

Please briefly explain why you feel this answer should be reported.

Please briefly explain why you feel this user should be reported.

Sign InSign Up

The NetSuite Pro

The NetSuite Pro Logo The NetSuite Pro Logo

The NetSuite Pro Navigation

  • Home
  • About Us
  • Tutorials
    • NetSuite Scripting
    • NetSuite Customization
    • NetSuite Integration
    • NetSuite Advanced PDF Templates
    • NetSuite Reporting & Analytics Guide
    • Real-World NetSuite Examples
  • Blog
  • Contact Us
Search
Ask A Question

Mobile menu

Close
Ask A Question
  • Home
  • About Us
  • Tutorials
    • NetSuite Scripting
    • NetSuite Customization
    • NetSuite Integration
    • NetSuite Advanced PDF Templates
    • NetSuite Reporting & Analytics Guide
    • Real-World NetSuite Examples
  • Blog
  • Contact Us
Home/ NetSuite + Slack Integration: The Complete Step-by-Step Guide/Approve Purchase Orders and Other NetSuite Records Right From Slack

Approve Purchase Orders and Other NetSuite Records Right From Slack

Part 7 of 13 in the NetSuite + Slack series
Time: 60 to 90 minutes  |  Level: Intermediate
You will learn: How to approve a purchase order from a Slack button, safely.
You will need: A sandbox, the bot token and signing secret, and a Suitelet you can make available without login.

Approvals are where a Slack integration pays for itself. Instead of an approver having to remember to log in, they get a message with the key facts and two buttons. One tap and the purchase order is approved in NetSuite.

Because a button click changes real financial data, this is also the page where we need to be most careful. We will build it in a way that is safe by design.

The journey of one approval

  1. A purchase order is saved and needs approval.
  2. A User Event script sends a Slack message to the approver with Approve and Reject buttons.
  3. The approver taps Approve.
  4. Slack sends the click to a Suitelet in NetSuite (your “front door”).
  5. The Suitelet checks the request really came from Slack, checks the person is allowed to approve, and updates the purchase order.
  6. The Suitelet updates the Slack message to say Approved by Jane Smith, so the buttons disappear and nobody clicks twice.

Before you start

  • You have finished Step 1, including the bot token and the signing secret.
  • You understand your purchase order approval setup. This page assumes standard approval routing is on, or that your process uses the Approval Status field. Do this in a sandbox and adjust to your own workflow.
  • You can create a Suitelet with an external URL (more on that below).

Part A: Send the approval request

This runs on the purchase order’s afterSubmit. It uses the bot token and chat.postMessage, so it can message a specific person.

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 */
define(['N/https', 'N/runtime', 'N/url', 'N/log'],
  (https, runtime, url, log) => {

    const afterSubmit = (context) => {
      try {
        if (context.type !== context.UserEventType.CREATE) return;

        const po = context.newRecord;
        // Only ask when the PO is actually waiting for approval.
        // In NetSuite, approval status "1" means Pending Approval.
        if (String(po.getValue({ fieldId: 'approvalstatus' })) !== '1') return;

        const script   = runtime.getCurrentScript();
        const token    = script.getParameter({ name: 'custscript_slack_bot_token' });
        const channel  = script.getParameter({ name: 'custscript_slack_approval_channel' }); // e.g. #po-approvals

        const tranId   = po.getValue({ fieldId: 'tranid' });
        const vendor   = po.getText({ fieldId: 'entity' });
        const total    = Number(po.getValue({ fieldId: 'total' })) || 0;
        const buyer    = po.getText({ fieldId: 'employee' }) || 'Unknown';

        const domain = url.resolveDomain({ hostType: url.HostType.APPLICATION });
        const link   = 'https://' + domain + url.resolveRecord({ recordType: 'purchaseorder', recordId: po.id });

        // The button "value" travels back to us when clicked. Keep it small and simple.
        const buttonValue = JSON.stringify({ type: 'purchaseorder', id: po.id });

        const payload = {
          channel: channel,
          text: 'Approval needed: PO ' + tranId,
          blocks: [
            { type: 'header', text: { type: 'plain_text', text: 'Purchase order needs approval' } },
            {
              type: 'section',
              fields: [
                { type: 'mrkdwn', text: '*PO*\n<' + link + '|' + tranId + '>' },
                { type: 'mrkdwn', text: '*Vendor*\n' + vendor },
                { type: 'mrkdwn', text: '*Amount*\n$' + total.toFixed(2) },
                { type: 'mrkdwn', text: '*Requested by*\n' + buyer }
              ]
            },
            {
              type: 'actions',
              block_id: 'po_approval',
              elements: [
                { type: 'button', style: 'primary', action_id: 'approve_po',
                  text: { type: 'plain_text', text: 'Approve' }, value: buttonValue },
                { type: 'button', style: 'danger', action_id: 'reject_po',
                  text: { type: 'plain_text', text: 'Reject' }, value: buttonValue }
              ]
            }
          ]
        };

        const response = https.post({
          url: 'https://slack.com/api/chat.postMessage',
          headers: {
            'Content-Type': 'application/json; charset=utf-8',
            'Authorization': 'Bearer ' + token
          },
          body: JSON.stringify(payload)
        });

        // Slack's Web API returns HTTP 200 even for many errors, so read the body.
        const result = JSON.parse(response.body);
        if (!result.ok) log.error('Slack postMessage failed', result.error);
      } catch (e) {
        log.error('Approval request failed', e.name + ': ' + e.message);
      }
    };

    return { afterSubmit };
  });

Two details worth pointing out:

  • Slack’s Web API answers HTTP 200 even when it fails. The real answer is in the JSON body ("ok": false, "error": "channel_not_found"). Always check result.ok.
  • The button value carries only an ID and a type, never anything sensitive. The Suitelet will load the real record itself.

Part B: Tell Slack where to send clicks

  1. In your Slack app settings, open Interactivity & Shortcuts and switch Interactivity on.
  2. For Request URL, paste the external URL of the Suitelet we are about to build. (Come back to this step after Part C.)
  3. Save.

Part C: The Suitelet that receives the click

Create a Suitelet that can be reached from the internet

Slack cannot log in to NetSuite, so the Suitelet deployment must be Available Without Login. That sounds scary, and it is the reason we verify every request in code. In the deployment, tick Available Without Login and use the External URL it shows you after you save.

The script

/**
 * @NApiVersion 2.1
 * @NScriptType Suitelet
 *
 * Receives button clicks from Slack for PO approvals.
 */
define(['N/crypto', 'N/encode', 'N/record', 'N/search', 'N/https', 'N/runtime', 'N/log'],
  (crypto, encode, record, search, https, runtime, log) => {

    // 1. Prove the request really came from Slack.
    const isFromSlack = (request) => {
      const signature = request.headers['X-Slack-Signature'] || request.headers['x-slack-signature'];
      const timestamp = request.headers['X-Slack-Request-Timestamp'] || request.headers['x-slack-request-timestamp'];
      if (!signature || !timestamp) return false;

      // Reject old requests (replay protection): older than 5 minutes.
      const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
      if (ageSeconds > 60 * 5) return false;

      const baseString = 'v0:' + timestamp + ':' + request.body;

      // Signing secret stored as an API Secret. Check Oracle's current N/crypto help for exact usage.
      const key = crypto.createSecretKey({
        secret: 'custsecret_slack_signing',
        encoding: encode.Encoding.UTF_8
      });
      const hmac = crypto.createHmac({ algorithm: crypto.HashAlg.SHA256, key: key });
      hmac.update({ input: baseString, inputEncoding: encode.Encoding.UTF_8 });
      const expected = 'v0=' + hmac.digest({ outputEncoding: encode.Encoding.HEX });

      return expected === String(signature).toLowerCase();
    };

    // 2. Match the Slack user to a NetSuite employee by email.
    const findApprover = (slackUserId, token) => {
      const res = https.get({
        url: 'https://slack.com/api/users.info?user=' + encodeURIComponent(slackUserId),
        headers: { 'Authorization': 'Bearer ' + token }
      });
      const data = JSON.parse(res.body);
      if (!data.ok || !data.user || !data.user.profile || !data.user.profile.email) return null;

      const found = search.create({
        type: search.Type.EMPLOYEE,
        filters: [['email', 'is', data.user.profile.email], 'AND', ['isinactive', 'is', 'F']],
        columns: ['entityid']
      }).run().getRange({ start: 0, end: 1 });

      return found.length ? { id: found[0].id, name: found[0].getValue('entityid') } : null;
    };

    const onRequest = (context) => {
      const request  = context.request;
      const response = context.response;

      if (request.method !== 'POST') {
        response.write('OK');
        return;
      }

      try {
        if (!isFromSlack(request)) {
          response.write('Unauthorized');
          log.audit('Rejected request', 'Signature check failed');
          return;
        }

        // Slack sends buttons as: payload=<url-encoded JSON>
        const payload = JSON.parse(request.parameters.payload);
        const action  = payload.actions[0];
        const item    = JSON.parse(action.value);
        const token   = runtime.getCurrentScript().getParameter({ name: 'custscript_slack_bot_token' });

        const approver = findApprover(payload.user.id, token);
        if (!approver) {
          respondToSlack(payload, ':no_entry: Sorry, I could not match your Slack account to a NetSuite employee.', false);
          response.write('');
          return;
        }

        // 3. Also check this person is allowed to approve this record.
        //    EXAMPLE ONLY: 'nextapprover' exists when advanced approval routing is on.
        //    Replace with your own rule, such as an approval limit or a role check.
        const nextApprover = search.lookupFields({
          type: search.Type.PURCHASE_ORDER,
          id: item.id,
          columns: ['nextapprover']
        });
        const nextId = nextApprover.nextapprover && nextApprover.nextapprover[0]
          ? nextApprover.nextapprover[0].value : null;
        if (nextId && String(nextId) !== String(approver.id)) {
          respondToSlack(payload, ':no_entry: You are not the current approver for this purchase order.', false);
          response.write('');
          return;
        }

        // 4. Make the change in NetSuite: 2 = Approved, 3 = Rejected.
        const approve = action.action_id === 'approve_po';
        record.submitFields({
          type: record.Type.PURCHASE_ORDER,
          id: item.id,
          values: { approvalstatus: approve ? '2' : '3' }
        });

        // 5. Update the Slack message so the buttons disappear.
        respondToSlack(
          payload,
          (approve ? ':white_check_mark: Approved' : ':x: Rejected') + ' by ' + approver.name,
          true
        );

        response.write('');   // Reply to Slack quickly (it expects an answer within 3 seconds).
      } catch (e) {
        log.error('Slack approval failed', e.name + ': ' + e.message);
        response.write('');
      }
    };

    // Replace the original message using the response_url Slack gives us.
    const respondToSlack = (payload, text, replaceOriginal) => {
      https.post({
        url: payload.response_url,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          replace_original: replaceOriginal,
          response_type: 'ephemeral',
          text: text
        })
      });
    };

    return { onRequest };
  });

Why the script does what it does

  • Signature check (step 1). Anyone who guesses your Suitelet URL could otherwise send a fake “approve” click. Slack signs every request with your signing secret, and we recompute and compare the signature. If it does not match, we stop.
  • Replay protection. Rejecting anything older than five minutes stops someone re-sending an old, genuine request.
  • Identity match (step 2). The Suitelet runs as the script owner, not as the approver, so Slack’s “who clicked” must be turned into a NetSuite employee. Matching on email is simple and works well when both systems use the work email address.
  • Permission check (step 3). Never assume that everyone in the channel is allowed to approve. Check the real rule, such as the record’s next approver, an approval limit, or a role.
  • Real update (step 4). submitFields is quick and light. Check that your own approval workflow does not need extra fields, comments or routing that this shortcut would skip.
  • Message update (step 5). Replacing the original stops double-clicks and leaves a visible record in Slack.

Part D: Test it safely

  1. In a sandbox, create a purchase order that goes to Pending Approval.
  2. Confirm the Slack message arrives with two buttons.
  3. Click Approve as a user who is the current approver. Check the PO status changes and the Slack message updates.
  4. Click as a user who is not the approver, and check you get the polite refusal.
  5. Send a request with a made-up signature (for example with a REST client) and confirm it is rejected. Look in the Execution Log for the “Rejected request” entry.

Important cautions

  • Slack expects a reply within 3 seconds. If your update might take longer (heavy workflows, many lines), have the Suitelet trigger a Map/Reduce or Scheduled script and reply straight away, then let that script update Slack afterwards.
  • A Suitelet that runs without login executes with the permissions of its owner. Keep the code narrow, validate everything, and never let a click choose an arbitrary record type or field to change. In the example, the record type is fixed and only approvalstatus is changed.
  • Header names and field names can differ by account setup. Check the x-slack-signature header spelling in your logs the first time, and confirm your approval field and status codes in your own account.
  • Keep an audit trail. Consider adding a system note or custom field like “Approved via Slack by at “.
  • Money limits still apply. If your business has approval limits by amount, enforce them in the Suitelet, not in Slack’s wording.

Other records that work well

The same pattern suits vendor bill approvals, expense report approvals, journal entry approvals, time-off requests, or a customer credit-hold release. Change the record type, the status field and the message layout.

What is next

You can push data out and take decisions back. Next, let people pull data on demand with a Slack slash command.


← Step 4: Build Rich, Readable Slack Messages from NetSuite (Block Kit)
Series overview
Look Up NetSuite Records With a Slack Slash Command →
Share
  • Facebook

Sidebar

Ask A Question

Stats

  • Questions 6
  • Answers 6
  • Best Answers 0
  • Users 9
  • Popular
  • Answers
  • Rocky

    Issue in running a client script in NetSuite SuiteScript 2.0 ...

    • 1 Answer
  • admin

    How can I send an email with an attachment in ...

    • 1 Answer
  • admin

    How do I avoid SSS_USAGE_LIMIT_EXCEEDED in a Map/Reduce script?

    • 1 Answer
  • admin
    admin added an answer The issue is usually caused by following Wrong script file… September 14, 2025 at 10:33 pm
  • admin
    admin added an answer Steps to send an Invoice PDF by email: define(['N/email', 'N/render',… August 28, 2025 at 3:05 am
  • admin
    admin added an answer This error means your script hit NetSuite’s governance usage limit… August 28, 2025 at 3:02 am

Top Members

Rocky

Rocky

  • 1 Question
  • 22 Points
Begginer
74gold

74gold

  • 0 Questions
  • 20 Points
Begginer
Sophie1022

Sophie1022

  • 0 Questions
  • 20 Points
Begginer

Trending Tags

clientscript netsuite scripting suitescript
  • The SuiteScript 1.0 scripts nobody remembers: Portlets, Mass Updates and Workflow ActionsSeptember 10, 2026
  • You’re on SuiteScript 2.0, not 1.0. Here’s what that actually buys you.September 9, 2026
  • Converting a SuiteScript 1.0 Suitelet to 2.1 (and the ones that were never really forms)September 8, 2026
  • Every NetSuite certification in 2026, and the rules that quietly changedSeptember 7, 2026
  • Converting a SuiteScript 1.0 Scheduled Script to a 2.1 Map/Reduce (and why a straight port is a mistake)September 7, 2026
  • Migrating a SuiteScript 1.0 RESTlet to SS2.1 + OAuth 2.0 before the 2027.1 deadlineAugust 15, 2026
  • How to convert a SuiteScript 1.0 User Event script to SuiteScript 2.1 (with real code)August 14, 2026
  • The complete SuiteScript 1.0 to 2.1 API cheat sheet: nlapiXxx β†’ N/moduleAugust 13, 2026
  • NetSuite Error Messages: Causes and FixesAugust 11, 2026
  • How to audit every SuiteScript 1.0 script in your NetSuite account (step by step)August 11, 2026

Explore

  • Home
  • Add group
  • Groups page
  • Communities
  • Questions
    • New Questions
    • Trending Questions
    • Must read Questions
    • Hot Questions
  • Polls
  • Tags
  • Badges
  • Users
  • Help

Footer

Menu

  • Home
  • About Us
  • Tutorials
    • NetSuite Scripting
    • NetSuite Customization
    • NetSuite Integration
    • NetSuite Advanced PDF Templates
    • NetSuite Reporting & Analytics Guide
    • Real-World NetSuite Examples
  • Blog
  • Contact Us

Quick Links

  • NetSuite Scripting
  • NetSuite Customization
  • NetSuite Advanced PDF Template
  • NetSuite Integration
  • NetSuite Reporting & Analytics

Subscribe for NetSuite Insights....

Β© 2026 The NetSuite Pro. All Rights Reserved