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
- A purchase order is saved and needs approval.
- A User Event script sends a Slack message to the approver with Approve and Reject buttons.
- The approver taps Approve.
- Slack sends the click to a Suitelet in NetSuite (your “front door”).
- The Suitelet checks the request really came from Slack, checks the person is allowed to approve, and updates the purchase order.
- 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 checkresult.ok. - The button
valuecarries 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
- In your Slack app settings, open Interactivity & Shortcuts and switch Interactivity on.
- For Request URL, paste the external URL of the Suitelet we are about to build. (Come back to this step after Part C.)
- 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).
submitFieldsis 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
- In a sandbox, create a purchase order that goes to Pending Approval.
- Confirm the Slack message arrives with two buttons.
- Click Approve as a user who is the current approver. Check the PO status changes and the Slack message updates.
- Click as a user who is not the approver, and check you get the polite refusal.
- 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
approvalstatusis changed. - Header names and field names can differ by account setup. Check the
x-slack-signatureheader 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.